From 9dec3f4536cfa9603a9970dafaf092e9800b5c3f Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 11 Mar 2026 22:00:35 +0000 Subject: [PATCH 01/49] admin stuff Signed-off-by: Ben --- auditlog.go | 11 ++++ entitlements.go | 43 +++++++++++++ premiumkeys.go | 62 ++++++++++++++++++- serverblacklist.go | 18 ++++++ .../count_all_user_subscriptions.sql | 11 ++++ .../list_all_user_subscriptions_paginated.sql | 13 ++++ sql/premium_keys/count_all.sql | 1 + sql/premium_keys/list_all.sql | 16 +++++ 8 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 sql/entitlements/count_all_user_subscriptions.sql create mode 100644 sql/entitlements/list_all_user_subscriptions_paginated.sql create mode 100644 sql/premium_keys/count_all.sql create mode 100644 sql/premium_keys/list_all.sql diff --git a/auditlog.go b/auditlog.go index 4c1949e..a39b0da 100644 --- a/auditlog.go +++ b/auditlog.go @@ -84,6 +84,14 @@ const ( AuditActionBotStaffAdd AuditActionType = 300 AuditActionBotStaffRemove AuditActionType = 301 + + AuditActionGlobalBlacklistAdd AuditActionType = 310 + AuditActionGlobalBlacklistRemove AuditActionType = 311 + + AuditActionServerBlacklistAdd AuditActionType = 320 + AuditActionServerBlacklistRemove AuditActionType = 321 + + AuditActionPremiumKeyGenerate AuditActionType = 330 ) type AuditResourceType int16 @@ -109,6 +117,9 @@ const ( AuditResourceBotStaff AuditResourceType = 18 AuditResourceTicketLabel AuditResourceType = 19 AuditResourceTicketLabelAssignment AuditResourceType = 20 + AuditResourceGlobalBlacklist AuditResourceType = 21 + AuditResourceServerBlacklist AuditResourceType = 22 + AuditResourcePremiumKey AuditResourceType = 23 ) type AuditLogEntry struct { diff --git a/entitlements.go b/entitlements.go index d672fa4..cc86784 100644 --- a/entitlements.go +++ b/entitlements.go @@ -49,6 +49,12 @@ var ( //go:embed sql/entitlements/update_expires_at.sql entitlementsUpdateExpiresAt string + + //go:embed sql/entitlements/list_all_user_subscriptions_paginated.sql + entitlementsListAllUserSubscriptionsPaginated string + + //go:embed sql/entitlements/count_all_user_subscriptions.sql + entitlementsCountAllUserSubscriptions string ) func newEntitlementsTable(db *pgxpool.Pool) *Entitlements { @@ -263,3 +269,40 @@ func (e *Entitlements) SetExpiresAt(ctx context.Context, tx pgx.Tx, id uuid.UUID _, err := tx.Exec(ctx, entitlementsUpdateExpiresAt, id, expiresAt) return err } + +func (e *Entitlements) ListAllUserSubscriptionsPaginated(ctx context.Context, gracePeriod time.Duration, limit, offset int) ([]model.GuildEntitlementEntry, error) { + rows, err := e.Query(ctx, entitlementsListAllUserSubscriptionsPaginated, gracePeriod, limit, offset) + if err != nil { + return nil, err + } + + var entries []model.GuildEntitlementEntry + for rows.Next() { + var entry model.GuildEntitlementEntry + if err := rows.Scan( + &entry.Id, + &entry.UserId, + &entry.Source, + &entry.ExpiresAt, + &entry.SkuId, + &entry.SkuLabel, + &entry.Tier, + &entry.SkuPriority, + ); err != nil { + return nil, err + } + + entries = append(entries, entry) + } + + return entries, nil +} + +func (e *Entitlements) CountAllUserSubscriptions(ctx context.Context, gracePeriod time.Duration) (int, error) { + var count int + if err := e.QueryRow(ctx, entitlementsCountAllUserSubscriptions, gracePeriod).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} diff --git a/premiumkeys.go b/premiumkeys.go index a2f5310..af04d71 100644 --- a/premiumkeys.go +++ b/premiumkeys.go @@ -2,17 +2,39 @@ package database import ( "context" + _ "embed" "errors" + "time" + "github.com/google/uuid" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" - "time" ) type PremiumKeys struct { *pgxpool.Pool } +var ( + //go:embed sql/premium_keys/list_all.sql + premiumKeysListAll string + + //go:embed sql/premium_keys/count_all.sql + premiumKeysCountAll string +) + +type PremiumKeyEntry struct { + Key uuid.UUID `json:"key"` + Length *time.Duration `json:"length"` + SkuId *uuid.UUID `json:"sku_id"` + GeneratedAt *time.Time `json:"generated_at"` + SkuLabel *string `json:"sku_label"` + Tier *string `json:"tier"` + GuildId *uint64 `json:"guild_id"` + ActivatedBy *uint64 `json:"activated_by"` + IsUsed bool `json:"is_used"` +} + func newPremiumKeys(db *pgxpool.Pool) *PremiumKeys { return &PremiumKeys{ db, @@ -52,3 +74,41 @@ func (k *PremiumKeys) Delete(ctx context.Context, tx pgx.Tx, key uuid.UUID) (tim return length, skuId, true, nil } + +func (k *PremiumKeys) ListAll(ctx context.Context, limit, offset int) ([]PremiumKeyEntry, error) { + rows, err := k.Query(ctx, premiumKeysListAll, limit, offset) + if err != nil { + return nil, err + } + + var entries []PremiumKeyEntry + for rows.Next() { + var entry PremiumKeyEntry + if err := rows.Scan( + &entry.Key, + &entry.Length, + &entry.SkuId, + &entry.GeneratedAt, + &entry.SkuLabel, + &entry.Tier, + &entry.GuildId, + &entry.ActivatedBy, + &entry.IsUsed, + ); err != nil { + return nil, err + } + + entries = append(entries, entry) + } + + return entries, nil +} + +func (k *PremiumKeys) CountAll(ctx context.Context) (int, error) { + var count int + if err := k.QueryRow(ctx, premiumKeysCountAll).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} diff --git a/serverblacklist.go b/serverblacklist.go index ab26ba7..fc4f852 100644 --- a/serverblacklist.go +++ b/serverblacklist.go @@ -104,3 +104,21 @@ func (b *ServerBlacklist) Delete(ctx context.Context, guildId uint64) (err error return } +func (b *ServerBlacklist) ListAllEntries(ctx context.Context) ([]ServerBlacklistEntry, error) { + query := `SELECT "guild_id", "reason", "owner_id", "real_owner_id" FROM server_blacklist;` + rows, err := b.Query(ctx, query) + if err != nil { + return nil, err + } + + var entries []ServerBlacklistEntry + for rows.Next() { + var entry ServerBlacklistEntry + if err := rows.Scan(&entry.GuildId, &entry.Reason, &entry.OwnerId, &entry.RealOwnerId); err != nil { + return nil, err + } + entries = append(entries, entry) + } + return entries, nil +} + diff --git a/sql/entitlements/count_all_user_subscriptions.sql b/sql/entitlements/count_all_user_subscriptions.sql new file mode 100644 index 0000000..247f8fa --- /dev/null +++ b/sql/entitlements/count_all_user_subscriptions.sql @@ -0,0 +1,11 @@ +SELECT COUNT(*) +FROM entitlements +INNER JOIN skus ON entitlements.sku_id = skus.id +INNER JOIN subscription_skus ON skus.id = subscription_skus.sku_id +WHERE + entitlements.user_id IS NOT NULL + AND + ( + entitlements.expires_at IS NULL OR + entitlements.expires_at > (NOW() - $1::interval) + ); diff --git a/sql/entitlements/list_all_user_subscriptions_paginated.sql b/sql/entitlements/list_all_user_subscriptions_paginated.sql new file mode 100644 index 0000000..b42d8a4 --- /dev/null +++ b/sql/entitlements/list_all_user_subscriptions_paginated.sql @@ -0,0 +1,13 @@ +SELECT entitlements.id, entitlements.user_id, entitlements.source, entitlements.expires_at, skus.id, skus.label, subscription_skus.tier, subscription_skus.priority +FROM entitlements +INNER JOIN skus ON entitlements.sku_id = skus.id +INNER JOIN subscription_skus ON skus.id = subscription_skus.sku_id +WHERE + entitlements.user_id IS NOT NULL + AND + ( + entitlements.expires_at IS NULL OR + entitlements.expires_at > (NOW() - $1::interval) + ) +ORDER BY entitlements.expires_at DESC NULLS FIRST +LIMIT $2 OFFSET $3; diff --git a/sql/premium_keys/count_all.sql b/sql/premium_keys/count_all.sql new file mode 100644 index 0000000..d18a42d --- /dev/null +++ b/sql/premium_keys/count_all.sql @@ -0,0 +1 @@ +SELECT (SELECT COUNT(*) FROM premium_keys) + (SELECT COUNT(*) FROM used_keys); diff --git a/sql/premium_keys/list_all.sql b/sql/premium_keys/list_all.sql new file mode 100644 index 0000000..252d6ec --- /dev/null +++ b/sql/premium_keys/list_all.sql @@ -0,0 +1,16 @@ +SELECT pk."key", pk."length", pk."sku_id", pk."generated_at", + skus."label" AS sku_label, subscription_skus."tier", + NULL::int8 AS guild_id, NULL::int8 AS activated_by, false AS is_used +FROM premium_keys pk +INNER JOIN skus ON pk.sku_id = skus.id +INNER JOIN subscription_skus ON skus.id = subscription_skus.sku_id + +UNION ALL + +SELECT uk."key", NULL::interval AS "length", NULL::uuid AS sku_id, + NULL::timestamptz AS generated_at, NULL AS sku_label, NULL::premium_tier AS tier, + uk."guild_id", uk."activated_by", true AS is_used +FROM used_keys uk + +ORDER BY is_used ASC, generated_at DESC NULLS LAST +LIMIT $1 OFFSET $2; From 5154cedc67f99306fba502df5f121b4231b447db Mon Sep 17 00:00:00 2001 From: biast12 Date: Sat, 21 Mar 2026 13:03:31 +0100 Subject: [PATCH 02/49] Unmarshal ButtonStyle as JSON string Change Panel.ButtonStyle json tag in panels.go to `"button_style,string"` so the int field is encoded/decoded as a JSON string. This ensures compatibility with APIs that represent button_style as a string and avoids unmarshal errors. --- panels.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/panels.go b/panels.go index 66ed213..0e866da 100644 --- a/panels.go +++ b/panels.go @@ -24,7 +24,7 @@ type Panel struct { CustomId string `json:"custom_id"` ImageUrl *string `json:"image_url,omitempty"` ThumbnailUrl *string `json:"thumbnail_url,omitempty"` - ButtonStyle int `json:"button_style"` + ButtonStyle int `json:"button_style,string"` ButtonLabel string `json:"button_label"` FormId *int `json:"form_id"` NamingScheme *string `json:"naming_scheme"` From ae864087dad8b7524d408ce80a87c2b7c0380e5f Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 22 Mar 2026 14:08:26 +0000 Subject: [PATCH 03/49] sku & pol poc Signed-off-by: Ben --- auditlog.go | 15 ++ database.go | 9 + multiserverskus.go | 18 ++ polarentitlements.go | 131 ++++++++++++ polarproducts.go | 187 ++++++++++++++++++ skus.go | 128 ++++++++++++ sql/multi_server_skus/delete.sql | 1 + sql/multi_server_skus/insert.sql | 3 + .../delete_by_subscription_id.sql | 2 + .../get_by_subscription_id.sql | 3 + sql/polar_entitlements/insert.sql | 5 + sql/polar_entitlements/list_by_user.sql | 18 ++ sql/polar_entitlements/schema.sql | 12 ++ sql/polar_entitlements/update_status.sql | 3 + sql/polar_products/delete.sql | 1 + .../get_by_polar_product_id.sql | 4 + sql/polar_products/insert.sql | 5 + sql/polar_products/list_all.sql | 4 + sql/polar_products/schema.sql | 14 ++ sql/polar_products/update.sql | 13 ++ sql/skus/delete.sql | 1 + sql/skus/get.sql | 1 + sql/skus/insert.sql | 3 + sql/skus/list_all.sql | 7 + sql/skus/update.sql | 1 + sql/subscription_skus/delete.sql | 1 + sql/subscription_skus/insert.sql | 3 + subscriptionskus.go | 18 ++ 28 files changed, 611 insertions(+) create mode 100644 polarentitlements.go create mode 100644 polarproducts.go create mode 100644 skus.go create mode 100644 sql/multi_server_skus/delete.sql create mode 100644 sql/multi_server_skus/insert.sql create mode 100644 sql/polar_entitlements/delete_by_subscription_id.sql create mode 100644 sql/polar_entitlements/get_by_subscription_id.sql create mode 100644 sql/polar_entitlements/insert.sql create mode 100644 sql/polar_entitlements/list_by_user.sql create mode 100644 sql/polar_entitlements/schema.sql create mode 100644 sql/polar_entitlements/update_status.sql create mode 100644 sql/polar_products/delete.sql create mode 100644 sql/polar_products/get_by_polar_product_id.sql create mode 100644 sql/polar_products/insert.sql create mode 100644 sql/polar_products/list_all.sql create mode 100644 sql/polar_products/schema.sql create mode 100644 sql/polar_products/update.sql create mode 100644 sql/skus/delete.sql create mode 100644 sql/skus/get.sql create mode 100644 sql/skus/insert.sql create mode 100644 sql/skus/list_all.sql create mode 100644 sql/skus/update.sql create mode 100644 sql/subscription_skus/delete.sql create mode 100644 sql/subscription_skus/insert.sql diff --git a/auditlog.go b/auditlog.go index a39b0da..caad8b5 100644 --- a/auditlog.go +++ b/auditlog.go @@ -92,6 +92,18 @@ const ( AuditActionServerBlacklistRemove AuditActionType = 321 AuditActionPremiumKeyGenerate AuditActionType = 330 + + AuditActionPolarCheckoutCreate AuditActionType = 340 + AuditActionPolarSubscriptionCancel AuditActionType = 341 + AuditActionPolarSubscriptionChange AuditActionType = 342 + + AuditActionPolarProductCreate AuditActionType = 350 + AuditActionPolarProductUpdate AuditActionType = 351 + AuditActionPolarProductDelete AuditActionType = 352 + + AuditActionSkuCreate AuditActionType = 360 + AuditActionSkuUpdate AuditActionType = 361 + AuditActionSkuDelete AuditActionType = 362 ) type AuditResourceType int16 @@ -120,6 +132,9 @@ const ( AuditResourceGlobalBlacklist AuditResourceType = 21 AuditResourceServerBlacklist AuditResourceType = 22 AuditResourcePremiumKey AuditResourceType = 23 + AuditResourcePolarSubscription AuditResourceType = 24 + AuditResourcePolarProduct AuditResourceType = 25 + AuditResourceSku AuditResourceType = 26 ) type AuditLogEntry struct { diff --git a/database.go b/database.go index 4c5c4de..b50ee3c 100644 --- a/database.go +++ b/database.go @@ -74,6 +74,8 @@ type Database struct { PanelHereMention *PanelHereMention Participants *ParticipantTable PatreonEntitlements *PatreonEntitlements + PolarEntitlements *PolarEntitlements + PolarProducts *PolarProducts Permissions *Permissions PremiumGuilds *PremiumGuilds PremiumKeys *PremiumKeys @@ -82,6 +84,7 @@ type Database struct { ServerBlacklist *ServerBlacklist ServiceRatings *ServiceRatings Settings *SettingsTable + Skus *Skus StaffOverride *StaffOverride SubscriptionSkus *SubscriptionSkus SupportTeam *SupportTeamTable @@ -176,6 +179,8 @@ func NewDatabase(pool *pgxpool.Pool) *Database { PanelHereMention: newPanelHereMention(pool), Participants: newParticipantTable(pool), PatreonEntitlements: newPatreonEntitlements(pool), + PolarEntitlements: newPolarEntitlements(pool), + PolarProducts: newPolarProducts(pool), Permissions: newPermissions(pool), PremiumGuilds: newPremiumGuilds(pool), PremiumKeys: newPremiumKeys(pool), @@ -184,6 +189,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { ServerBlacklist: newServerBlacklist(pool), ServiceRatings: newServiceRatings(pool), Settings: newSettingsTable(pool), + Skus: newSkusTable(pool), StaffOverride: newStaffOverride(pool), SubscriptionSkus: newSubscriptionSkusTable(pool), SupportTeam: newSupportTeamTable(pool), @@ -264,6 +270,7 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.Entitlements, d.Experiment, d.DiscordEntitlements, // depends on entitlements + d.Skus, // must be created before discord_store_skus, subscription_skus, multi_server_skus, polar_products d.DiscordStoreSkus, // depends on skus d.SubscriptionSkus, // depends on skus d.FeedbackEnabled, @@ -294,6 +301,8 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.PanelUserMention, d.PanelHereMention, d.PatreonEntitlements, + d.PolarEntitlements, // depends on entitlements + d.PolarProducts, // depends on skus d.Permissions, d.PremiumGuilds, d.PremiumKeys, diff --git a/multiserverskus.go b/multiserverskus.go index 30e46fd..523db7a 100644 --- a/multiserverskus.go +++ b/multiserverskus.go @@ -19,6 +19,12 @@ var ( //go:embed sql/multi_server_skus/get_permitted_server_count.sql multiServerSkusGetPermittedServerCount string + + //go:embed sql/multi_server_skus/insert.sql + multiServerSkusInsert string + + //go:embed sql/multi_server_skus/delete.sql + multiServerSkusDelete string ) func newMultiServerSkusTable(db *pgxpool.Pool) *MultiServerSkus { @@ -31,6 +37,18 @@ func (MultiServerSkus) Schema() string { return multiServerSkusSchema } +// Upsert inserts or updates a multi-server SKU entry within a transaction. +func (m *MultiServerSkus) Upsert(ctx context.Context, tx pgx.Tx, skuId uuid.UUID, serversPermitted int) error { + _, err := tx.Exec(ctx, multiServerSkusInsert, skuId, serversPermitted) + return err +} + +// DeleteBySku removes a multi-server SKU entry by its SKU ID within a transaction. +func (m *MultiServerSkus) DeleteBySku(ctx context.Context, tx pgx.Tx, skuId uuid.UUID) error { + _, err := tx.Exec(ctx, multiServerSkusDelete, skuId) + return err +} + func (m *MultiServerSkus) GetPermittedServerCount(ctx context.Context, tx pgx.Tx, skuId uuid.UUID) (int, bool, error) { var count int if err := tx.QueryRow(ctx, multiServerSkusGetPermittedServerCount, skuId).Scan(&count); err != nil { diff --git a/polarentitlements.go b/polarentitlements.go new file mode 100644 index 0000000..4644e45 --- /dev/null +++ b/polarentitlements.go @@ -0,0 +1,131 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PolarEntitlements struct { + *pgxpool.Pool +} + +func newPolarEntitlements(db *pgxpool.Pool) *PolarEntitlements { + return &PolarEntitlements{ + db, + } +} + +type PolarEntitlement struct { + PolarSubscriptionId string `json:"polar_subscription_id"` + EntitlementId uuid.UUID `json:"entitlement_id"` + UserId uint64 `json:"user_id"` + PolarProductId string `json:"polar_product_id"` + Status string `json:"status"` +} + +type PolarEntitlementWithDetails struct { + PolarEntitlement + SkuId uuid.UUID `json:"sku_id"` + SkuLabel string `json:"sku_label"` + Tier string `json:"tier"` + ExpiresAt *time.Time `json:"expires_at"` + GuildId *uint64 `json:"guild_id"` + PermittedServerCount *int `json:"permitted_server_count,omitempty"` +} + +var ( + //go:embed sql/polar_entitlements/schema.sql + polarEntitlementsSchema string + + //go:embed sql/polar_entitlements/insert.sql + polarEntitlementsInsert string + + //go:embed sql/polar_entitlements/get_by_subscription_id.sql + polarEntitlementsGetBySubscriptionId string + + //go:embed sql/polar_entitlements/list_by_user.sql + polarEntitlementsListByUser string + + //go:embed sql/polar_entitlements/delete_by_subscription_id.sql + polarEntitlementsDeleteBySubscriptionId string + + //go:embed sql/polar_entitlements/update_status.sql + polarEntitlementsUpdateStatus string +) + +func (e PolarEntitlements) Schema() string { + return polarEntitlementsSchema +} + +func (e *PolarEntitlements) Insert(ctx context.Context, tx pgx.Tx, polarSubscriptionId string, entitlementId uuid.UUID, userId uint64, polarProductId string, status string) error { + _, err := tx.Exec(ctx, polarEntitlementsInsert, polarSubscriptionId, entitlementId, userId, polarProductId, status) + return err +} + +func (e *PolarEntitlements) GetBySubscriptionId(ctx context.Context, tx pgx.Tx, polarSubscriptionId string) (*PolarEntitlement, error) { + var ent PolarEntitlement + err := tx.QueryRow(ctx, polarEntitlementsGetBySubscriptionId, polarSubscriptionId).Scan( + &ent.PolarSubscriptionId, + &ent.EntitlementId, + &ent.UserId, + &ent.PolarProductId, + &ent.Status, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return &ent, nil +} + +func (e *PolarEntitlements) ListByUser(ctx context.Context, tx pgx.Tx, userId uint64) ([]PolarEntitlementWithDetails, error) { + rows, err := tx.Query(ctx, polarEntitlementsListByUser, userId) + if err != nil { + return nil, err + } + + defer rows.Close() + + var entitlements []PolarEntitlementWithDetails + for rows.Next() { + var ent PolarEntitlementWithDetails + if err := rows.Scan( + &ent.PolarSubscriptionId, + &ent.EntitlementId, + &ent.UserId, + &ent.PolarProductId, + &ent.Status, + &ent.SkuId, + &ent.SkuLabel, + &ent.Tier, + &ent.ExpiresAt, + &ent.GuildId, + &ent.PermittedServerCount, + ); err != nil { + return nil, err + } + + entitlements = append(entitlements, ent) + } + + return entitlements, nil +} + +func (e *PolarEntitlements) DeleteBySubscriptionId(ctx context.Context, tx pgx.Tx, polarSubscriptionId string) error { + _, err := tx.Exec(ctx, polarEntitlementsDeleteBySubscriptionId, polarSubscriptionId) + return err +} + +func (e *PolarEntitlements) UpdateStatus(ctx context.Context, tx pgx.Tx, polarSubscriptionId string, status string) error { + _, err := tx.Exec(ctx, polarEntitlementsUpdateStatus, polarSubscriptionId, status) + return err +} diff --git a/polarproducts.go b/polarproducts.go new file mode 100644 index 0000000..840b7e9 --- /dev/null +++ b/polarproducts.go @@ -0,0 +1,187 @@ +package database + +import ( + "context" + _ "embed" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PolarProducts struct { + *pgxpool.Pool +} + +func newPolarProducts(db *pgxpool.Pool) *PolarProducts { + return &PolarProducts{db} +} + +type PolarProduct struct { + Id uuid.UUID `json:"id"` + PolarProductId string `json:"polar_product_id"` + SkuId uuid.UUID `json:"sku_id"` + Name string `json:"name"` + Description string `json:"description"` + Interval string `json:"interval"` + PriceGbp int `json:"price_gbp"` + Features []string `json:"features"` + Highlighted bool `json:"highlighted"` + SortOrder int `json:"sort_order"` + Tier string `json:"tier"` + ServersPermitted *int `json:"servers_permitted,omitempty"` +} + +var ( + //go:embed sql/polar_products/schema.sql + polarProductsSchema string + + //go:embed sql/polar_products/list_all.sql + polarProductsListAll string + + //go:embed sql/polar_products/get_by_polar_product_id.sql + polarProductsGetByPolarProductId string + + //go:embed sql/polar_products/insert.sql + polarProductsInsert string + + //go:embed sql/polar_products/update.sql + polarProductsUpdate string + + //go:embed sql/polar_products/delete.sql + polarProductsDelete string +) + +func (t PolarProducts) Schema() string { + return polarProductsSchema +} + +func scanPolarProduct(row pgx.Row) (PolarProduct, error) { + var p PolarProduct + var features pgtype.TextArray + + err := row.Scan( + &p.Id, + &p.PolarProductId, + &p.SkuId, + &p.Name, + &p.Description, + &p.Interval, + &p.PriceGbp, + &features, + &p.Highlighted, + &p.SortOrder, + &p.Tier, + &p.ServersPermitted, + ) + if err != nil { + return p, err + } + + p.Features = make([]string, 0) + if features.Status == pgtype.Present { + for _, el := range features.Elements { + if el.Status == pgtype.Present { + p.Features = append(p.Features, el.String) + } + } + } + + return p, nil +} + +func (t *PolarProducts) ListAll(ctx context.Context) ([]PolarProduct, error) { + rows, err := t.Pool.Query(ctx, polarProductsListAll) + if err != nil { + return nil, err + } + defer rows.Close() + + var products []PolarProduct + for rows.Next() { + p, err := scanPolarProduct(rows) + if err != nil { + return nil, err + } + products = append(products, p) + } + + if products == nil { + products = make([]PolarProduct, 0) + } + + return products, nil +} + +func (t *PolarProducts) GetByPolarProductId(ctx context.Context, polarProductId string) (*PolarProduct, error) { + p, err := scanPolarProduct(t.Pool.QueryRow(ctx, polarProductsGetByPolarProductId, polarProductId)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +func (t *PolarProducts) Create(ctx context.Context, tx pgx.Tx, product PolarProduct) (PolarProduct, error) { + featuresArray := toTextArray(product.Features) + return scanPolarProduct(tx.QueryRow(ctx, polarProductsInsert, + product.PolarProductId, + product.SkuId, + product.Name, + product.Description, + product.Interval, + product.PriceGbp, + featuresArray, + product.Highlighted, + product.SortOrder, + product.Tier, + product.ServersPermitted, + )) +} + +func (t *PolarProducts) Update(ctx context.Context, tx pgx.Tx, product PolarProduct) error { + featuresArray := toTextArray(product.Features) + _, err := tx.Exec(ctx, polarProductsUpdate, + product.Id, + product.PolarProductId, + product.SkuId, + product.Name, + product.Description, + product.Interval, + product.PriceGbp, + featuresArray, + product.Highlighted, + product.SortOrder, + product.Tier, + product.ServersPermitted, + ) + return err +} + +func (t *PolarProducts) Delete(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { + _, err := tx.Exec(ctx, polarProductsDelete, id) + return err +} + +func toTextArray(strs []string) pgtype.TextArray { + if len(strs) == 0 { + return pgtype.TextArray{ + Status: pgtype.Present, + } + } + + elements := make([]pgtype.Text, len(strs)) + for i, s := range strs { + elements[i] = pgtype.Text{String: s, Status: pgtype.Present} + } + + return pgtype.TextArray{ + Elements: elements, + Dimensions: []pgtype.ArrayDimension{{Length: int32(len(strs)), LowerBound: 1}}, + Status: pgtype.Present, + } +} diff --git a/skus.go b/skus.go new file mode 100644 index 0000000..6bb9f27 --- /dev/null +++ b/skus.go @@ -0,0 +1,128 @@ +package database + +import ( + "context" + _ "embed" + "errors" + + "github.com/TicketsBot-cloud/common/model" + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type Skus struct { + *pgxpool.Pool +} + +// SkuWithDetails represents a SKU with its optional subscription and multi-server details. +type SkuWithDetails struct { + model.Sku + Tier *string `json:"tier,omitempty"` + Priority *int32 `json:"priority,omitempty"` + IsGlobal *bool `json:"is_global,omitempty"` + ServersPermitted *int `json:"servers_permitted,omitempty"` +} + +var ( + //go:embed sql/skus/schema.sql + skusSchema string + + //go:embed sql/skus/get.sql + skusGet string + + //go:embed sql/skus/insert.sql + skusInsert string + + //go:embed sql/skus/update.sql + skusUpdate string + + //go:embed sql/skus/delete.sql + skusDelete string + + //go:embed sql/skus/list_all.sql + skusListAll string +) + +func newSkusTable(db *pgxpool.Pool) *Skus { + return &Skus{ + db, + } +} + +func (Skus) Schema() string { + return skusSchema +} + +// ListAll returns all SKUs with their subscription and multi-server details. +func (s *Skus) ListAll(ctx context.Context) ([]SkuWithDetails, error) { + rows, err := s.Query(ctx, skusListAll) + if err != nil { + return nil, err + } + defer rows.Close() + + var skus []SkuWithDetails + for rows.Next() { + var sku SkuWithDetails + if err := rows.Scan( + &sku.Id, + &sku.Label, + &sku.SkuType, + &sku.Tier, + &sku.Priority, + &sku.IsGlobal, + &sku.ServersPermitted, + ); err != nil { + return nil, err + } + + skus = append(skus, sku) + } + + return skus, nil +} + +// GetById returns a single SKU with its details, or nil if not found. +func (s *Skus) GetById(ctx context.Context, id uuid.UUID) (*SkuWithDetails, error) { + var sku SkuWithDetails + if err := s.QueryRow(ctx, skusGet, id).Scan( + &sku.Id, + &sku.Label, + &sku.SkuType, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + + return nil, err + } + + return &sku, nil +} + +// Create inserts a new SKU and its optional subscription and multi-server details within a transaction. +func (s *Skus) Create(ctx context.Context, tx pgx.Tx, label string, skuType model.SkuType) (model.Sku, error) { + var sku model.Sku + if err := tx.QueryRow(ctx, skusInsert, label, skuType).Scan( + &sku.Id, + &sku.Label, + &sku.SkuType, + ); err != nil { + return model.Sku{}, err + } + + return sku, nil +} + +// Update updates the base SKU fields within a transaction. +func (s *Skus) Update(ctx context.Context, tx pgx.Tx, id uuid.UUID, label string, skuType model.SkuType) error { + _, err := tx.Exec(ctx, skusUpdate, id, label, skuType) + return err +} + +// Delete removes a SKU by its ID within a transaction. +func (s *Skus) Delete(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { + _, err := tx.Exec(ctx, skusDelete, id) + return err +} diff --git a/sql/multi_server_skus/delete.sql b/sql/multi_server_skus/delete.sql new file mode 100644 index 0000000..969d515 --- /dev/null +++ b/sql/multi_server_skus/delete.sql @@ -0,0 +1 @@ +DELETE FROM multi_server_skus WHERE "sku_id" = $1; diff --git a/sql/multi_server_skus/insert.sql b/sql/multi_server_skus/insert.sql new file mode 100644 index 0000000..9d9ef6b --- /dev/null +++ b/sql/multi_server_skus/insert.sql @@ -0,0 +1,3 @@ +INSERT INTO multi_server_skus ("sku_id", "servers_permitted") +VALUES ($1, $2) +ON CONFLICT ("sku_id") DO UPDATE SET "servers_permitted" = $2; diff --git a/sql/polar_entitlements/delete_by_subscription_id.sql b/sql/polar_entitlements/delete_by_subscription_id.sql new file mode 100644 index 0000000..ca33936 --- /dev/null +++ b/sql/polar_entitlements/delete_by_subscription_id.sql @@ -0,0 +1,2 @@ +DELETE FROM polar_entitlements +WHERE "polar_subscription_id" = $1; diff --git a/sql/polar_entitlements/get_by_subscription_id.sql b/sql/polar_entitlements/get_by_subscription_id.sql new file mode 100644 index 0000000..0c96c33 --- /dev/null +++ b/sql/polar_entitlements/get_by_subscription_id.sql @@ -0,0 +1,3 @@ +SELECT "polar_subscription_id", "entitlement_id", "user_id", "polar_product_id", "status" +FROM polar_entitlements +WHERE "polar_subscription_id" = $1; diff --git a/sql/polar_entitlements/insert.sql b/sql/polar_entitlements/insert.sql new file mode 100644 index 0000000..94ec1fd --- /dev/null +++ b/sql/polar_entitlements/insert.sql @@ -0,0 +1,5 @@ +INSERT INTO polar_entitlements ("polar_subscription_id", "entitlement_id", "user_id", "polar_product_id", "status") +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT ("polar_subscription_id") DO UPDATE SET + "entitlement_id" = EXCLUDED."entitlement_id", + "status" = EXCLUDED."status"; diff --git a/sql/polar_entitlements/list_by_user.sql b/sql/polar_entitlements/list_by_user.sql new file mode 100644 index 0000000..4e2ebd5 --- /dev/null +++ b/sql/polar_entitlements/list_by_user.sql @@ -0,0 +1,18 @@ +SELECT + pe."polar_subscription_id", + pe."entitlement_id", + pe."user_id", + pe."polar_product_id", + pe."status", + e."sku_id", + s."label" AS "sku_label", + ss."tier", + e."expires_at", + e."guild_id", + ms."servers_permitted" +FROM polar_entitlements pe +INNER JOIN entitlements e ON pe."entitlement_id" = e."id" +INNER JOIN skus s ON e."sku_id" = s."id" +INNER JOIN subscription_skus ss ON s."id" = ss."sku_id" +LEFT JOIN multi_server_skus ms ON s."id" = ms."sku_id" +WHERE pe."user_id" = $1; diff --git a/sql/polar_entitlements/schema.sql b/sql/polar_entitlements/schema.sql new file mode 100644 index 0000000..5d38cba --- /dev/null +++ b/sql/polar_entitlements/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS polar_entitlements ( + "polar_subscription_id" TEXT NOT NULL, + "entitlement_id" UUID NOT NULL, + "user_id" INT8 NOT NULL, + "polar_product_id" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + PRIMARY KEY ("polar_subscription_id"), + FOREIGN KEY ("entitlement_id") REFERENCES entitlements ("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS polar_entitlements_user_id_idx ON polar_entitlements("user_id"); +CREATE INDEX IF NOT EXISTS polar_entitlements_entitlement_id_idx ON polar_entitlements("entitlement_id"); diff --git a/sql/polar_entitlements/update_status.sql b/sql/polar_entitlements/update_status.sql new file mode 100644 index 0000000..244b81e --- /dev/null +++ b/sql/polar_entitlements/update_status.sql @@ -0,0 +1,3 @@ +UPDATE polar_entitlements +SET "status" = $2 +WHERE "polar_subscription_id" = $1; diff --git a/sql/polar_products/delete.sql b/sql/polar_products/delete.sql new file mode 100644 index 0000000..5d2de79 --- /dev/null +++ b/sql/polar_products/delete.sql @@ -0,0 +1 @@ +DELETE FROM polar_products WHERE "id" = $1; diff --git a/sql/polar_products/get_by_polar_product_id.sql b/sql/polar_products/get_by_polar_product_id.sql new file mode 100644 index 0000000..5b2cd5d --- /dev/null +++ b/sql/polar_products/get_by_polar_product_id.sql @@ -0,0 +1,4 @@ +SELECT "id", "polar_product_id", "sku_id", "name", "description", "interval", + "price_gbp", "features", "highlighted", "sort_order", "tier", "servers_permitted" +FROM polar_products +WHERE "polar_product_id" = $1; diff --git a/sql/polar_products/insert.sql b/sql/polar_products/insert.sql new file mode 100644 index 0000000..5bb3f30 --- /dev/null +++ b/sql/polar_products/insert.sql @@ -0,0 +1,5 @@ +INSERT INTO polar_products ("polar_product_id", "sku_id", "name", "description", "interval", + "price_gbp", "features", "highlighted", "sort_order", "tier", "servers_permitted") +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +RETURNING "id", "polar_product_id", "sku_id", "name", "description", "interval", + "price_gbp", "features", "highlighted", "sort_order", "tier", "servers_permitted"; diff --git a/sql/polar_products/list_all.sql b/sql/polar_products/list_all.sql new file mode 100644 index 0000000..02a70b6 --- /dev/null +++ b/sql/polar_products/list_all.sql @@ -0,0 +1,4 @@ +SELECT "id", "polar_product_id", "sku_id", "name", "description", "interval", + "price_gbp", "features", "highlighted", "sort_order", "tier", "servers_permitted" +FROM polar_products +ORDER BY "sort_order", "price_gbp"; diff --git a/sql/polar_products/schema.sql b/sql/polar_products/schema.sql new file mode 100644 index 0000000..e3d9ab4 --- /dev/null +++ b/sql/polar_products/schema.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS polar_products ( + "id" UUID DEFAULT gen_random_uuid() PRIMARY KEY, + "polar_product_id" TEXT NOT NULL UNIQUE, + "sku_id" UUID NOT NULL REFERENCES skus("id"), + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "interval" TEXT NOT NULL DEFAULT 'month', + "price_gbp" INT NOT NULL, + "features" TEXT[] NOT NULL DEFAULT '{}', + "highlighted" BOOLEAN NOT NULL DEFAULT false, + "sort_order" INT NOT NULL DEFAULT 0, + "tier" TEXT NOT NULL DEFAULT 'premium', + "servers_permitted" INT DEFAULT NULL +); diff --git a/sql/polar_products/update.sql b/sql/polar_products/update.sql new file mode 100644 index 0000000..d4400cb --- /dev/null +++ b/sql/polar_products/update.sql @@ -0,0 +1,13 @@ +UPDATE polar_products +SET "polar_product_id" = $2, + "sku_id" = $3, + "name" = $4, + "description" = $5, + "interval" = $6, + "price_gbp" = $7, + "features" = $8, + "highlighted" = $9, + "sort_order" = $10, + "tier" = $11, + "servers_permitted" = $12 +WHERE "id" = $1; diff --git a/sql/skus/delete.sql b/sql/skus/delete.sql new file mode 100644 index 0000000..a496252 --- /dev/null +++ b/sql/skus/delete.sql @@ -0,0 +1 @@ +DELETE FROM skus WHERE "id" = $1; diff --git a/sql/skus/get.sql b/sql/skus/get.sql new file mode 100644 index 0000000..ab86502 --- /dev/null +++ b/sql/skus/get.sql @@ -0,0 +1 @@ +SELECT "id", "label", "type" FROM skus WHERE "id" = $1; diff --git a/sql/skus/insert.sql b/sql/skus/insert.sql new file mode 100644 index 0000000..accc481 --- /dev/null +++ b/sql/skus/insert.sql @@ -0,0 +1,3 @@ +INSERT INTO skus ("label", "type") +VALUES ($1, $2) +RETURNING "id", "label", "type"; diff --git a/sql/skus/list_all.sql b/sql/skus/list_all.sql new file mode 100644 index 0000000..ec526ea --- /dev/null +++ b/sql/skus/list_all.sql @@ -0,0 +1,7 @@ +SELECT s."id", s."label", s."type", + ss."tier", ss."priority", ss."is_global", + ms."servers_permitted" +FROM skus s +LEFT JOIN subscription_skus ss ON s."id" = ss."sku_id" +LEFT JOIN multi_server_skus ms ON s."id" = ms."sku_id" +ORDER BY s."label"; diff --git a/sql/skus/update.sql b/sql/skus/update.sql new file mode 100644 index 0000000..d02e7ba --- /dev/null +++ b/sql/skus/update.sql @@ -0,0 +1 @@ +UPDATE skus SET "label" = $2, "type" = $3 WHERE "id" = $1; diff --git a/sql/subscription_skus/delete.sql b/sql/subscription_skus/delete.sql new file mode 100644 index 0000000..8654a24 --- /dev/null +++ b/sql/subscription_skus/delete.sql @@ -0,0 +1 @@ +DELETE FROM subscription_skus WHERE "sku_id" = $1; diff --git a/sql/subscription_skus/insert.sql b/sql/subscription_skus/insert.sql new file mode 100644 index 0000000..5ef931b --- /dev/null +++ b/sql/subscription_skus/insert.sql @@ -0,0 +1,3 @@ +INSERT INTO subscription_skus ("sku_id", "tier", "priority", "is_global") +VALUES ($1, $2, $3, $4) +ON CONFLICT ("sku_id") DO UPDATE SET "tier" = $2, "priority" = $3, "is_global" = $4; diff --git a/subscriptionskus.go b/subscriptionskus.go index 94001ae..3a23a70 100644 --- a/subscriptionskus.go +++ b/subscriptionskus.go @@ -24,6 +24,12 @@ var ( //go:embed sql/subscription_skus/search.sql subscriptionSkusSearch string + + //go:embed sql/subscription_skus/insert.sql + subscriptionSkusInsert string + + //go:embed sql/subscription_skus/delete.sql + subscriptionSkusDelete string ) func newSubscriptionSkusTable(db *pgxpool.Pool) *SubscriptionSkus { @@ -56,6 +62,18 @@ func (e *SubscriptionSkus) GetSku(ctx context.Context, tx pgx.Tx, skuId uuid.UUI return &sku, nil } +// Upsert inserts or updates a subscription SKU entry within a transaction. +func (e *SubscriptionSkus) Upsert(ctx context.Context, tx pgx.Tx, skuId uuid.UUID, tier model.EntitlementTier, priority int32, isGlobal bool) error { + _, err := tx.Exec(ctx, subscriptionSkusInsert, skuId, tier, priority, isGlobal) + return err +} + +// DeleteBySku removes a subscription SKU entry by its SKU ID within a transaction. +func (e *SubscriptionSkus) DeleteBySku(ctx context.Context, tx pgx.Tx, skuId uuid.UUID) error { + _, err := tx.Exec(ctx, subscriptionSkusDelete, skuId) + return err +} + func (e *SubscriptionSkus) Search(ctx context.Context, label string, limit int) ([]model.SubscriptionSku, error) { rows, err := e.Query(ctx, subscriptionSkusSearch, label, limit) if err != nil { From 0757728abacd690fbd93bdf911957d00b681b212 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Apr 2026 18:16:28 +0100 Subject: [PATCH 04/49] kb Signed-off-by: Ben --- auditlog.go | 9 ++ database.go | 9 ++ kbarticle.go | 395 +++++++++++++++++++++++++++++++++++++++++++++ kbcategory.go | 116 +++++++++++++ panelkbcategory.go | 81 ++++++++++ tag.go | 57 +++++-- 6 files changed, 658 insertions(+), 9 deletions(-) create mode 100644 kbarticle.go create mode 100644 kbcategory.go create mode 100644 panelkbcategory.go diff --git a/auditlog.go b/auditlog.go index caad8b5..5e57982 100644 --- a/auditlog.go +++ b/auditlog.go @@ -104,6 +104,13 @@ const ( AuditActionSkuCreate AuditActionType = 360 AuditActionSkuUpdate AuditActionType = 361 AuditActionSkuDelete AuditActionType = 362 + + AuditActionKBArticleCreate AuditActionType = 370 + AuditActionKBArticleUpdate AuditActionType = 371 + AuditActionKBArticleDelete AuditActionType = 372 + AuditActionKBCategoryCreate AuditActionType = 380 + AuditActionKBCategoryUpdate AuditActionType = 381 + AuditActionKBCategoryDelete AuditActionType = 382 ) type AuditResourceType int16 @@ -135,6 +142,8 @@ const ( AuditResourcePolarSubscription AuditResourceType = 24 AuditResourcePolarProduct AuditResourceType = 25 AuditResourceSku AuditResourceType = 26 + AuditResourceKBArticle AuditResourceType = 27 + AuditResourceKBCategory AuditResourceType = 28 ) type AuditLogEntry struct { diff --git a/database.go b/database.go index b50ee3c..91b560c 100644 --- a/database.go +++ b/database.go @@ -56,6 +56,8 @@ type Database struct { GuildMetadata *GuildMetadataTable ImportLogs *ImportLogsTable ImportMappingTable *ImportMappingTable + KBArticles *KBArticlesTable + KBCategories *KBCategoriesTable LegacyPremiumEntitlementGuilds *LegacyPremiumEntitlementGuilds LegacyPremiumEntitlements *LegacyPremiumEntitlements MultiPanels *MultiPanelTable @@ -65,6 +67,7 @@ type Database struct { OnCall *OnCall Panel *PanelTable PanelAccessControlRules *PanelAccessControlRules + PanelKBCategories *PanelKBCategoriesTable PanelRoleMentions *PanelRoleMentions PanelSupportHours *PanelSupportHoursTable PanelSupportHoursSettings *PanelSupportHoursSettingsTable @@ -161,6 +164,8 @@ func NewDatabase(pool *pgxpool.Pool) *Database { GuildMetadata: newGuildMetadataTable(pool), ImportLogs: newImportLogs(pool), ImportMappingTable: newImportMapping(pool), + KBArticles: newKBArticles(pool), + KBCategories: newKBCategories(pool), LegacyPremiumEntitlementGuilds: newLegacyPremiumEntitlementGuildsTable(pool), LegacyPremiumEntitlements: newLegacyPremiumEntitlement(pool), MultiPanels: newMultiMultiPanelTable(pool), @@ -170,6 +175,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { OnCall: newOnCall(pool), Panel: newPanelTable(pool), PanelAccessControlRules: newPanelAccessControlRules(pool), + PanelKBCategories: newPanelKBCategories(pool), PanelRoleMentions: newPanelRoleMentions(pool), PanelSupportHours: newPanelSupportHoursTable(pool), PanelSupportHoursSettings: newPanelSupportHoursSettingsTable(pool), @@ -317,6 +323,9 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.SupportTeamPermissions, // must be created after support_team table d.PanelTeams, // Must be created after panels & support teams tables d.Tag, + d.KBCategories, // Knowledge base categories + d.KBArticles, // Knowledge base articles (references categories) + d.PanelKBCategories, // Panel-to-KB-category associations d.TicketLimit, d.TicketPermissions, d.Tickets, // Must be created before members table diff --git a/kbarticle.go b/kbarticle.go new file mode 100644 index 0000000..3f50567 --- /dev/null +++ b/kbarticle.go @@ -0,0 +1,395 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type KBArticle struct { + Id int `json:"id"` + GuildId uint64 `json:"guild_id,string"` + Title string `json:"title"` + Slug string `json:"slug"` + Content *string `json:"content"` + Embed *CustomEmbedWithFields `json:"embed"` + CategoryIds []int `json:"category_ids"` + Keywords []string `json:"keywords"` + Position int `json:"position"` + Published bool `json:"published"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type KBArticlesTable struct { + *pgxpool.Pool +} + +func newKBArticles(db *pgxpool.Pool) *KBArticlesTable { + return &KBArticlesTable{ + Pool: db, + } +} + +func (t KBArticlesTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS kb_articles( + "id" SERIAL, + "guild_id" int8 NOT NULL, + "title" varchar(100) NOT NULL, + "slug" varchar(100) NOT NULL, + "content" text DEFAULT NULL CONSTRAINT kb_content_length CHECK (length(content) <= 4096), + "embed" JSONB DEFAULT NULL, + "category_ids" int4[] DEFAULT '{}', + "keywords" text[] DEFAULT '{}', + "position" int4 NOT NULL DEFAULT 0, + "published" bool NOT NULL DEFAULT true, + "search_vector" tsvector, + "created_at" timestamptz NOT NULL DEFAULT NOW(), + "updated_at" timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY("id"), + UNIQUE("guild_id", "slug") +); +CREATE INDEX IF NOT EXISTS kb_articles_guild_id_idx ON kb_articles("guild_id"); +CREATE INDEX IF NOT EXISTS kb_articles_keywords_idx ON kb_articles USING GIN("keywords"); + +CREATE INDEX IF NOT EXISTS kb_articles_search_idx ON kb_articles USING GIN("search_vector"); + +CREATE OR REPLACE FUNCTION kb_articles_search_vector_update() RETURNS trigger AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(NEW.content, '')), 'B') || + setweight(to_tsvector('english', coalesce(array_to_string(NEW.keywords, ' '), '')), 'C'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS kb_articles_search_vector_trigger ON kb_articles; +CREATE TRIGGER kb_articles_search_vector_trigger + BEFORE INSERT OR UPDATE ON kb_articles + FOR EACH ROW EXECUTE FUNCTION kb_articles_search_vector_update(); +` +} + +var kbArticleColumns = `"id", "guild_id", "title", "slug", "content", "embed", "category_ids", "keywords", "position", "published", "created_at", "updated_at"` + +func scanKBArticle(row pgx.Row) (KBArticle, error) { + var article KBArticle + var embedRaw *string + var categoryIds pgtype.Int4Array + var keywords pgtype.TextArray + + err := row.Scan( + &article.Id, + &article.GuildId, + &article.Title, + &article.Slug, + &article.Content, + &embedRaw, + &categoryIds, + &keywords, + &article.Position, + &article.Published, + &article.CreatedAt, + &article.UpdatedAt, + ) + if err != nil { + return article, err + } + + if embedRaw != nil { + if err := json.UnmarshalFromString(*embedRaw, &article.Embed); err != nil { + return article, err + } + } + + article.CategoryIds = make([]int, 0) + if categoryIds.Status == pgtype.Present { + for _, el := range categoryIds.Elements { + if el.Status == pgtype.Present { + article.CategoryIds = append(article.CategoryIds, int(el.Int)) + } + } + } + + article.Keywords = make([]string, 0) + if keywords.Status == pgtype.Present { + for _, el := range keywords.Elements { + if el.Status == pgtype.Present { + article.Keywords = append(article.Keywords, el.String) + } + } + } + + return article, nil +} + +func (t *KBArticlesTable) GetByGuild(ctx context.Context, guildId uint64) ([]KBArticle, error) { + query := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "guild_id" = $1 +ORDER BY "position" ASC, "id" ASC; +` + + rows, err := t.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var articles []KBArticle + for rows.Next() { + article, err := scanKBArticle(rows) + if err != nil { + return nil, err + } + articles = append(articles, article) + } + + return articles, nil +} + +func (t *KBArticlesTable) Get(ctx context.Context, id int) (KBArticle, bool, error) { + query := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "id" = $1; +` + + article, err := scanKBArticle(t.QueryRow(ctx, query, id)) + if err != nil { + if err == pgx.ErrNoRows { + return KBArticle{}, false, nil + } + return KBArticle{}, false, err + } + + return article, true, nil +} + +func (t *KBArticlesTable) GetBySlug(ctx context.Context, guildId uint64, slug string) (KBArticle, bool, error) { + query := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "guild_id" = $1 AND "slug" = $2 AND "published" = true; +` + + article, err := scanKBArticle(t.QueryRow(ctx, query, guildId, slug)) + if err != nil { + if err == pgx.ErrNoRows { + return KBArticle{}, false, nil + } + return KBArticle{}, false, err + } + + return article, true, nil +} + +func (t *KBArticlesTable) GetByCategory(ctx context.Context, guildId uint64, categoryId int) ([]KBArticle, error) { + query := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "guild_id" = $1 AND $2 = ANY("category_ids") AND "published" = true +ORDER BY "position" ASC, "id" ASC; +` + + rows, err := t.Query(ctx, query, guildId, categoryId) + if err != nil { + return nil, err + } + defer rows.Close() + + var articles []KBArticle + for rows.Next() { + article, err := scanKBArticle(rows) + if err != nil { + return nil, err + } + articles = append(articles, article) + } + + return articles, nil +} + +func (t *KBArticlesTable) Search(ctx context.Context, guildId uint64, query string, limit int) ([]KBArticle, error) { + // Use full-text search with ts_rank for relevance, plus ILIKE fallback for partial matches. + // Title matches (weight A) rank highest, then content (B), then keywords (C). + q := ` +SELECT ` + kbArticleColumns + ` +FROM kb_articles +WHERE "guild_id" = $1 AND "published" = true + AND ( + ("search_vector" IS NOT NULL AND "search_vector" @@ websearch_to_tsquery('english', $2)) + OR "title" ILIKE '%' || $2 || '%' + OR "content" ILIKE '%' || $2 || '%' + OR EXISTS (SELECT 1 FROM unnest("keywords") kw WHERE kw ILIKE '%' || $2 || '%') + ) +ORDER BY + ts_rank(coalesce("search_vector", ''::tsvector), websearch_to_tsquery('english', $2)) DESC, + CASE WHEN "title" ILIKE $2 || '%' THEN 0 ELSE 1 END, + "position" ASC +LIMIT $3; +` + + rows, err := t.Query(ctx, q, guildId, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var articles []KBArticle + for rows.Next() { + article, err := scanKBArticle(rows) + if err != nil { + return nil, err + } + articles = append(articles, article) + } + + return articles, nil +} + +func (t *KBArticlesTable) SearchContaining(ctx context.Context, guildId uint64, substring string, limit int) ([]KBArticleSummary, error) { + // Full-text search for autocomplete, with ILIKE fallback for partial/short queries + query := ` +SELECT "id", "title" +FROM kb_articles +WHERE "guild_id" = $1 AND "published" = true + AND ( + ("search_vector" IS NOT NULL AND "search_vector" @@ websearch_to_tsquery('english', $2)) + OR "title" ILIKE '%' || $2 || '%' + ) +ORDER BY + ts_rank(coalesce("search_vector", ''::tsvector), websearch_to_tsquery('english', $2)) DESC, + CASE WHEN "title" ILIKE $2 || '%' THEN 0 ELSE 1 END +LIMIT $3; +` + + rows, err := t.Query(ctx, query, guildId, substring, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []KBArticleSummary + for rows.Next() { + var s KBArticleSummary + if err := rows.Scan(&s.Id, &s.Title); err != nil { + return nil, err + } + results = append(results, s) + } + + return results, nil +} + +// KBArticleSummary is a lightweight projection returned by autocomplete searches. +type KBArticleSummary struct { + Id int `json:"id"` + Title string `json:"title"` +} + +func (t *KBArticlesTable) Create(ctx context.Context, article KBArticle) (int, error) { + query := ` +INSERT INTO kb_articles("guild_id", "title", "slug", "content", "embed", "category_ids", "keywords", "position", "published") +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING "id"; +` + + var embedRaw *string + if article.Embed != nil { + tmp, err := json.MarshalToString(article.Embed) + if err != nil { + return 0, err + } + embedRaw = &tmp + } + + categoryIds := toInt4Array(article.CategoryIds) + keywords := toTextArray(article.Keywords) + + var id int + err := t.QueryRow(ctx, query, + article.GuildId, + article.Title, + article.Slug, + article.Content, + embedRaw, + categoryIds, + keywords, + article.Position, + article.Published, + ).Scan(&id) + return id, err +} + +func (t *KBArticlesTable) Update(ctx context.Context, article KBArticle) error { + query := ` +UPDATE kb_articles +SET "title" = $2, "slug" = $3, "content" = $4, "embed" = $5, "category_ids" = $6, "keywords" = $7, "position" = $8, "published" = $9, "updated_at" = NOW() +WHERE "id" = $1 AND "guild_id" = $10; +` + + var embedRaw *string + if article.Embed != nil { + tmp, err := json.MarshalToString(article.Embed) + if err != nil { + return err + } + embedRaw = &tmp + } + + categoryIds := toInt4Array(article.CategoryIds) + keywords := toTextArray(article.Keywords) + + _, err := t.Exec(ctx, query, + article.Id, + article.Title, + article.Slug, + article.Content, + embedRaw, + categoryIds, + keywords, + article.Position, + article.Published, + article.GuildId, + ) + return err +} + +func (t *KBArticlesTable) Delete(ctx context.Context, guildId uint64, articleId int) error { + query := `DELETE FROM kb_articles WHERE "guild_id" = $1 AND "id" = $2;` + _, err := t.Exec(ctx, query, guildId, articleId) + return err +} + +func (t *KBArticlesTable) GetCountByGuild(ctx context.Context, guildId uint64) (int, error) { + query := `SELECT COUNT(*) FROM kb_articles WHERE "guild_id" = $1;` + var count int + err := t.QueryRow(ctx, query, guildId).Scan(&count) + return count, err +} + +func toInt4Array(ints []int) pgtype.Int4Array { + if len(ints) == 0 { + return pgtype.Int4Array{ + Status: pgtype.Present, + } + } + + elements := make([]pgtype.Int4, len(ints)) + for i, v := range ints { + elements[i] = pgtype.Int4{Int: int32(v), Status: pgtype.Present} + } + + return pgtype.Int4Array{ + Elements: elements, + Dimensions: []pgtype.ArrayDimension{{Length: int32(len(ints)), LowerBound: 1}}, + Status: pgtype.Present, + } +} diff --git a/kbcategory.go b/kbcategory.go new file mode 100644 index 0000000..91c52f5 --- /dev/null +++ b/kbcategory.go @@ -0,0 +1,116 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type KBCategory struct { + Id int `json:"id"` + GuildId uint64 `json:"guild_id,string"` + Name string `json:"name"` + Emoji *string `json:"emoji"` + Position int `json:"position"` +} + +type KBCategoriesTable struct { + *pgxpool.Pool +} + +func newKBCategories(db *pgxpool.Pool) *KBCategoriesTable { + return &KBCategoriesTable{ + Pool: db, + } +} + +func (t KBCategoriesTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS kb_categories( + "id" SERIAL, + "guild_id" int8 NOT NULL, + "name" varchar(50) NOT NULL, + "emoji" varchar(64) DEFAULT NULL, + "position" int4 NOT NULL DEFAULT 0, + PRIMARY KEY("id"), + UNIQUE("guild_id", "name") +); +CREATE INDEX IF NOT EXISTS kb_categories_guild_id_idx ON kb_categories("guild_id"); +` +} + +func (t *KBCategoriesTable) GetByGuild(ctx context.Context, guildId uint64) ([]KBCategory, error) { + query := ` +SELECT "id", "guild_id", "name", "emoji", "position" +FROM kb_categories +WHERE "guild_id" = $1 +ORDER BY "position" ASC, "id" ASC; +` + + rows, err := t.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var categories []KBCategory + for rows.Next() { + var cat KBCategory + if err := rows.Scan(&cat.Id, &cat.GuildId, &cat.Name, &cat.Emoji, &cat.Position); err != nil { + return nil, err + } + + categories = append(categories, cat) + } + + return categories, nil +} + +func (t *KBCategoriesTable) Get(ctx context.Context, id int) (KBCategory, bool, error) { + query := ` +SELECT "id", "guild_id", "name", "emoji", "position" +FROM kb_categories +WHERE "id" = $1; +` + + var cat KBCategory + err := t.QueryRow(ctx, query, id).Scan(&cat.Id, &cat.GuildId, &cat.Name, &cat.Emoji, &cat.Position) + if err != nil { + if err == pgx.ErrNoRows { + return KBCategory{}, false, nil + } + return KBCategory{}, false, err + } + + return cat, true, nil +} + +func (t *KBCategoriesTable) Create(ctx context.Context, cat KBCategory) (int, error) { + query := ` +INSERT INTO kb_categories("guild_id", "name", "emoji", "position") +VALUES($1, $2, $3, $4) +RETURNING "id"; +` + + var id int + err := t.QueryRow(ctx, query, cat.GuildId, cat.Name, cat.Emoji, cat.Position).Scan(&id) + return id, err +} + +func (t *KBCategoriesTable) Update(ctx context.Context, cat KBCategory) error { + query := ` +UPDATE kb_categories +SET "name" = $2, "emoji" = $3, "position" = $4 +WHERE "id" = $1 AND "guild_id" = $5; +` + + _, err := t.Exec(ctx, query, cat.Id, cat.Name, cat.Emoji, cat.Position, cat.GuildId) + return err +} + +func (t *KBCategoriesTable) Delete(ctx context.Context, guildId uint64, id int) error { + query := `DELETE FROM kb_categories WHERE "id" = $1 AND "guild_id" = $2;` + _, err := t.Exec(ctx, query, id, guildId) + return err +} diff --git a/panelkbcategory.go b/panelkbcategory.go new file mode 100644 index 0000000..48faa08 --- /dev/null +++ b/panelkbcategory.go @@ -0,0 +1,81 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PanelKBCategoriesTable struct { + *pgxpool.Pool +} + +func newPanelKBCategories(db *pgxpool.Pool) *PanelKBCategoriesTable { + return &PanelKBCategoriesTable{ + Pool: db, + } +} + +func (t PanelKBCategoriesTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS panel_kb_categories( + "panel_id" int4 NOT NULL, + "category_id" int4 NOT NULL, + PRIMARY KEY("panel_id", "category_id") +); +` +} + +func (t *PanelKBCategoriesTable) GetByPanel(ctx context.Context, panelId int) ([]int, error) { + query := `SELECT "category_id" FROM panel_kb_categories WHERE "panel_id" = $1;` + + rows, err := t.Query(ctx, query, panelId) + if err != nil { + return nil, err + } + defer rows.Close() + + var categoryIds []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + categoryIds = append(categoryIds, id) + } + + return categoryIds, nil +} + +func (t *PanelKBCategoriesTable) Set(ctx context.Context, panelId int, categoryIds []int) error { + tx, err := t.Begin(ctx) + if err != nil { + return err + } + + defer tx.Rollback(ctx) + + if err := t.SetWithTx(ctx, tx, panelId, categoryIds); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (t *PanelKBCategoriesTable) SetWithTx(ctx context.Context, tx pgx.Tx, panelId int, categoryIds []int) error { + // Remove existing category associations for this panel + if _, err := tx.Exec(ctx, `DELETE FROM panel_kb_categories WHERE "panel_id" = $1;`, panelId); err != nil { + return err + } + + // Insert each provided category association + for _, categoryId := range categoryIds { + query := `INSERT INTO panel_kb_categories("panel_id", "category_id") VALUES($1, $2) ON CONFLICT("panel_id", "category_id") DO NOTHING;` + if _, err := tx.Exec(ctx, query, panelId, categoryId); err != nil { + return err + } + } + + return nil +} diff --git a/tag.go b/tag.go index b57582a..d7297d7 100644 --- a/tag.go +++ b/tag.go @@ -14,6 +14,7 @@ type Tag struct { Content *string Embed *CustomEmbedWithFields ApplicationCommandId *uint64 + KBArticleId *int `json:"kb_article_id"` } type TagsTable struct { @@ -38,6 +39,8 @@ CREATE TABLE IF NOT EXISTS tags( PRIMARY KEY("guild_id", "tag_id") ); CREATE INDEX IF NOT EXISTS tags_guild_id_idx ON tags("guild_id"); + +ALTER TABLE tags ADD COLUMN IF NOT EXISTS "kb_article_id" int4 DEFAULT NULL; ` } @@ -49,7 +52,7 @@ func (t *TagsTable) Exists(ctx context.Context, guildId uint64, tagId string) (e func (t *TagsTable) Get(ctx context.Context, guildId uint64, tagId string) (Tag, bool, error) { query := ` -SELECT LOWER(tag_id), "guild_id", "content", "embed", "application_command_id" +SELECT LOWER(tag_id), "guild_id", "content", "embed", "application_command_id", "kb_article_id" FROM tags WHERE "guild_id" = $1 AND LOWER("tag_id") = LOWER($2); ` @@ -62,6 +65,7 @@ WHERE "guild_id" = $1 AND LOWER("tag_id") = LOWER($2); &tag.Content, &embedRaw, &tag.ApplicationCommandId, + &tag.KBArticleId, ) if err != nil { @@ -105,14 +109,14 @@ func (t *TagsTable) GetTagIds(ctx context.Context, guildId uint64) (ids []string func (t *TagsTable) GetByApplicationCommandId(ctx context.Context, guildId, applicationCommandId uint64) (Tag, bool, error) { query := ` -SELECT LOWER(tags.tag_id), tags.guild_id, tags.content, tags.embed, tags.application_command_id +SELECT LOWER(tags.tag_id), tags.guild_id, tags.content, tags.embed, tags.application_command_id, tags.kb_article_id FROM tags WHERE "guild_id" = $1 AND "application_command_id" = $2; ` var tag Tag var embedRaw *string - if err := t.QueryRow(ctx, query, guildId, applicationCommandId).Scan(&tag.Id, &tag.GuildId, &tag.Content, &embedRaw, &tag.ApplicationCommandId); err != nil { + if err := t.QueryRow(ctx, query, guildId, applicationCommandId).Scan(&tag.Id, &tag.GuildId, &tag.Content, &embedRaw, &tag.ApplicationCommandId, &tag.KBArticleId); err != nil { if errors.Is(err, pgx.ErrNoRows) { return Tag{}, false, nil } @@ -131,7 +135,7 @@ WHERE "guild_id" = $1 AND "application_command_id" = $2; func (t *TagsTable) GetByGuild(ctx context.Context, guildId uint64) (map[string]Tag, error) { query := ` -SELECT LOWER(tags.tag_id), tags.guild_id, tags.content, tags.embed, tags.application_command_id +SELECT LOWER(tags.tag_id), tags.guild_id, tags.content, tags.embed, tags.application_command_id, tags.kb_article_id FROM tags WHERE "guild_id" = $1;` @@ -146,7 +150,7 @@ WHERE "guild_id" = $1;` for rows.Next() { var tag Tag var embedRaw *string - if err := rows.Scan(&tag.Id, &tag.GuildId, &tag.Content, &embedRaw, &tag.ApplicationCommandId); err != nil { + if err := rows.Scan(&tag.Id, &tag.GuildId, &tag.Content, &embedRaw, &tag.ApplicationCommandId, &tag.KBArticleId); err != nil { return nil, err } @@ -216,10 +220,10 @@ func (t *TagsTable) GetStartingWith(ctx context.Context, guildId uint64, prefix func (t *TagsTable) Set(ctx context.Context, tag Tag) error { query := ` -INSERT INTO tags("tag_id", "guild_id", "content", "embed", "application_command_id") -VALUES(LOWER($1), $2, $3, $4, $5) +INSERT INTO tags("tag_id", "guild_id", "content", "embed", "application_command_id", "kb_article_id") +VALUES(LOWER($1), $2, $3, $4, $5, $6) ON CONFLICT("tag_id", "guild_id") DO -UPDATE SET "content" = $3, "embed" = $4, "application_command_id" = $5;` +UPDATE SET "content" = $3, "embed" = $4, "application_command_id" = $5, "kb_article_id" = $6;` var embedRaw *string if tag.Embed != nil { @@ -231,10 +235,45 @@ UPDATE SET "content" = $3, "embed" = $4, "application_command_id" = $5;` embedRaw = &tmp } - _, err := t.Exec(ctx, query, tag.Id, tag.GuildId, tag.Content, embedRaw, tag.ApplicationCommandId) + _, err := t.Exec(ctx, query, tag.Id, tag.GuildId, tag.Content, embedRaw, tag.ApplicationCommandId, tag.KBArticleId) return err } +func (t *TagsTable) GetByKBArticleId(ctx context.Context, guildId uint64, articleId int) (Tag, bool, error) { + query := ` +SELECT LOWER(tag_id), "guild_id", "content", "embed", "application_command_id", "kb_article_id" +FROM tags +WHERE "guild_id" = $1 AND "kb_article_id" = $2 +LIMIT 1; +` + + var tag Tag + var embedRaw *string + err := t.QueryRow(ctx, query, guildId, articleId).Scan( + &tag.Id, + &tag.GuildId, + &tag.Content, + &embedRaw, + &tag.ApplicationCommandId, + &tag.KBArticleId, + ) + + if err != nil { + if err == pgx.ErrNoRows { + return Tag{}, false, nil + } + return Tag{}, false, err + } + + if embedRaw != nil { + if err := json.UnmarshalFromString(*embedRaw, &tag.Embed); err != nil { + return Tag{}, false, err + } + } + + return tag, true, nil +} + func (t *TagsTable) Delete(ctx context.Context, guildId uint64, tagId string) (err error) { query := ` DELETE FROM tags From 658595e2acf05433c7b8af16ad93cd083923a36e Mon Sep 17 00:00:00 2001 From: biast12 Date: Wed, 8 Apr 2026 22:46:19 +0200 Subject: [PATCH 05/49] Add AuditActionTicketCloseRequest constant Introduce a new AuditActionType constant AuditActionTicketCloseRequest (value 94) in auditlog.go to represent ticket close request actions in the audit log. This distinguishes user-initiated close requests from direct ticket close events for clearer auditing. --- auditlog.go | 1 + 1 file changed, 1 insertion(+) diff --git a/auditlog.go b/auditlog.go index 5e57982..734497c 100644 --- a/auditlog.go +++ b/auditlog.go @@ -56,6 +56,7 @@ const ( AuditActionTicketSendTag AuditActionType = 91 AuditActionTicketClose AuditActionType = 92 AuditActionTicketCloseReasonUpdate AuditActionType = 93 + AuditActionTicketCloseRequest AuditActionType = 94 AuditActionGuildIntegrationActivate AuditActionType = 100 AuditActionGuildIntegrationUpdate AuditActionType = 101 From e45551401707ae52f6bb96a17caef3a0de1dad10 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 10 Apr 2026 19:50:15 +0100 Subject: [PATCH 06/49] kb cust Signed-off-by: Ben --- auditlog.go | 3 ++ database.go | 3 ++ kbsettings.go | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 kbsettings.go diff --git a/auditlog.go b/auditlog.go index 734497c..5bf0468 100644 --- a/auditlog.go +++ b/auditlog.go @@ -112,6 +112,8 @@ const ( AuditActionKBCategoryCreate AuditActionType = 380 AuditActionKBCategoryUpdate AuditActionType = 381 AuditActionKBCategoryDelete AuditActionType = 382 + + AuditActionKBSettingsUpdate AuditActionType = 390 ) type AuditResourceType int16 @@ -145,6 +147,7 @@ const ( AuditResourceSku AuditResourceType = 26 AuditResourceKBArticle AuditResourceType = 27 AuditResourceKBCategory AuditResourceType = 28 + AuditResourceKBSettings AuditResourceType = 29 ) type AuditLogEntry struct { diff --git a/database.go b/database.go index 91b560c..f9b0a6b 100644 --- a/database.go +++ b/database.go @@ -58,6 +58,7 @@ type Database struct { ImportMappingTable *ImportMappingTable KBArticles *KBArticlesTable KBCategories *KBCategoriesTable + KBSettings *KBSettingsTable LegacyPremiumEntitlementGuilds *LegacyPremiumEntitlementGuilds LegacyPremiumEntitlements *LegacyPremiumEntitlements MultiPanels *MultiPanelTable @@ -166,6 +167,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { ImportMappingTable: newImportMapping(pool), KBArticles: newKBArticles(pool), KBCategories: newKBCategories(pool), + KBSettings: newKBSettings(pool), LegacyPremiumEntitlementGuilds: newLegacyPremiumEntitlementGuildsTable(pool), LegacyPremiumEntitlements: newLegacyPremiumEntitlement(pool), MultiPanels: newMultiMultiPanelTable(pool), @@ -325,6 +327,7 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.Tag, d.KBCategories, // Knowledge base categories d.KBArticles, // Knowledge base articles (references categories) + d.KBSettings, // Knowledge base customisation settings d.PanelKBCategories, // Panel-to-KB-category associations d.TicketLimit, d.TicketPermissions, diff --git a/kbsettings.go b/kbsettings.go new file mode 100644 index 0000000..b6ee6b2 --- /dev/null +++ b/kbsettings.go @@ -0,0 +1,126 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type KBSettings struct { + GuildId uint64 `json:"guild_id,string"` + PrimaryBg *int `json:"primary_bg"` + CardBg *int `json:"card_bg"` + TextColour *int `json:"text_colour"` + AccentColour *int `json:"accent_colour"` + LogoUrl *string `json:"logo_url"` + HideBranding bool `json:"hide_branding"` + CustomDomain *string `json:"custom_domain"` + DomainVerified bool `json:"domain_verified"` +} + +type KBSettingsTable struct { + *pgxpool.Pool +} + +func newKBSettings(db *pgxpool.Pool) *KBSettingsTable { + return &KBSettingsTable{ + Pool: db, + } +} + +func (t KBSettingsTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS kb_settings( + "guild_id" int8 NOT NULL PRIMARY KEY, + "primary_bg" int4 DEFAULT NULL, + "card_bg" int4 DEFAULT NULL, + "text_colour" int4 DEFAULT NULL, + "accent_colour" int4 DEFAULT NULL, + "logo_url" text DEFAULT NULL, + "hide_branding" bool NOT NULL DEFAULT false, + "custom_domain" text DEFAULT NULL, + "domain_verified" bool NOT NULL DEFAULT false +); +CREATE INDEX IF NOT EXISTS kb_settings_custom_domain_idx ON kb_settings("custom_domain") WHERE "custom_domain" IS NOT NULL AND "domain_verified" = true; +` +} + +func (t *KBSettingsTable) Get(ctx context.Context, guildId uint64) (KBSettings, bool, error) { + query := ` +SELECT "guild_id", "primary_bg", "card_bg", "text_colour", "accent_colour", "logo_url", "hide_branding", "custom_domain", "domain_verified" +FROM kb_settings +WHERE "guild_id" = $1; +` + + var settings KBSettings + err := t.QueryRow(ctx, query, guildId).Scan( + &settings.GuildId, + &settings.PrimaryBg, + &settings.CardBg, + &settings.TextColour, + &settings.AccentColour, + &settings.LogoUrl, + &settings.HideBranding, + &settings.CustomDomain, + &settings.DomainVerified, + ) + + if err != nil { + if err == pgx.ErrNoRows { + return KBSettings{}, false, nil + } + return KBSettings{}, false, err + } + + return settings, true, nil +} + +func (t *KBSettingsTable) Set(ctx context.Context, settings KBSettings) error { + query := ` +INSERT INTO kb_settings("guild_id", "primary_bg", "card_bg", "text_colour", "accent_colour", "logo_url", "hide_branding", "custom_domain", "domain_verified") +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT("guild_id") DO UPDATE SET + "primary_bg" = $2, + "card_bg" = $3, + "text_colour" = $4, + "accent_colour" = $5, + "logo_url" = $6, + "hide_branding" = $7, + "custom_domain" = $8, + "domain_verified" = $9; +` + + _, err := t.Exec(ctx, query, + settings.GuildId, + settings.PrimaryBg, + settings.CardBg, + settings.TextColour, + settings.AccentColour, + settings.LogoUrl, + settings.HideBranding, + settings.CustomDomain, + settings.DomainVerified, + ) + return err +} + +// GetAllVerifiedDomains returns all verified custom KB domains. +func (t *KBSettingsTable) GetAllVerifiedDomains(ctx context.Context) ([]string, error) { + query := `SELECT "custom_domain" FROM kb_settings WHERE "custom_domain" IS NOT NULL AND "domain_verified" = true;` + rows, err := t.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var domains []string + for rows.Next() { + var domain string + if err := rows.Scan(&domain); err != nil { + return nil, err + } + domains = append(domains, domain) + } + return domains, nil +} From 5043826a74233a66245510be6f5013c231fc3751 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 11 Apr 2026 18:44:57 +0100 Subject: [PATCH 07/49] kb settings Signed-off-by: Ben --- kbsettings.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/kbsettings.go b/kbsettings.go index b6ee6b2..3694add 100644 --- a/kbsettings.go +++ b/kbsettings.go @@ -105,6 +105,21 @@ ON CONFLICT("guild_id") DO UPDATE SET return err } +// GetGuildByDomain returns the guild ID that has claimed a given custom domain (verified or not). +// Returns 0, false if no guild has this domain. +func (t *KBSettingsTable) GetGuildByDomain(ctx context.Context, domain string) (uint64, bool, error) { + query := `SELECT "guild_id" FROM kb_settings WHERE "custom_domain" = $1 LIMIT 1;` + var guildId uint64 + err := t.QueryRow(ctx, query, domain).Scan(&guildId) + if err != nil { + if err == pgx.ErrNoRows { + return 0, false, nil + } + return 0, false, err + } + return guildId, true, nil +} + // GetAllVerifiedDomains returns all verified custom KB domains. func (t *KBSettingsTable) GetAllVerifiedDomains(ctx context.Context) ([]string, error) { query := `SELECT "custom_domain" FROM kb_settings WHERE "custom_domain" IS NOT NULL AND "domain_verified" = true;` From f346e7e132c9946be1b1dafabb2125169c43ec12 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 18 Apr 2026 18:19:40 +0100 Subject: [PATCH 08/49] gallery & integrations admin Signed-off-by: Ben --- auditlog.go | 10 + custom_integration.go | 249 +++++++++++++++++++-- database.go | 6 + gallery_listing_tags.go | 111 ++++++++++ gallery_listings.go | 469 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 829 insertions(+), 16 deletions(-) create mode 100644 gallery_listing_tags.go create mode 100644 gallery_listings.go diff --git a/auditlog.go b/auditlog.go index 5bf0468..52b0341 100644 --- a/auditlog.go +++ b/auditlog.go @@ -76,6 +76,9 @@ const ( AuditActionUserIntegrationUpdate AuditActionType = 201 AuditActionUserIntegrationDelete AuditActionType = 202 AuditActionUserIntegrationSetPublic AuditActionType = 203 + AuditActionUserIntegrationApprove AuditActionType = 204 + AuditActionUserIntegrationReject AuditActionType = 205 + AuditActionUserIntegrationUnapprove AuditActionType = 206 AuditActionWhitelabelCreate AuditActionType = 210 AuditActionWhitelabelDelete AuditActionType = 211 @@ -114,6 +117,12 @@ const ( AuditActionKBCategoryDelete AuditActionType = 382 AuditActionKBSettingsUpdate AuditActionType = 390 + + AuditActionGallerySubmit AuditActionType = 400 + AuditActionGalleryApprove AuditActionType = 401 + AuditActionGalleryReject AuditActionType = 402 + AuditActionGalleryRemove AuditActionType = 403 + AuditActionGalleryImport AuditActionType = 404 ) type AuditResourceType int16 @@ -148,6 +157,7 @@ const ( AuditResourceKBArticle AuditResourceType = 27 AuditResourceKBCategory AuditResourceType = 28 AuditResourceKBSettings AuditResourceType = 29 + AuditResourceGalleryListing AuditResourceType = 30 ) type AuditLogEntry struct { diff --git a/custom_integration.go b/custom_integration.go index c08e29e..f75341f 100644 --- a/custom_integration.go +++ b/custom_integration.go @@ -2,6 +2,8 @@ package database import ( "context" + "time" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" @@ -12,17 +14,20 @@ type CustomIntegrationTable struct { } type CustomIntegration struct { - Id int `json:"id"` - OwnerId uint64 `json:"owner_id"` - HttpMethod string `json:"http_method"` - WebhookUrl string `json:"webhook_url"` - ValidationUrl *string `json:"validation_url"` - Name string `json:"name"` - Description string `json:"description"` - ImageUrl *string `json:"image_url"` - PrivacyPolicyUrl *string `json:"privacy_policy_url"` - Public bool `json:"public"` - Approved bool `json:"approved"` + Id int `json:"id"` + OwnerId uint64 `json:"owner_id"` + HttpMethod string `json:"http_method"` + WebhookUrl string `json:"webhook_url"` + ValidationUrl *string `json:"validation_url"` + Name string `json:"name"` + Description string `json:"description"` + ImageUrl *string `json:"image_url"` + PrivacyPolicyUrl *string `json:"privacy_policy_url"` + Public bool `json:"public"` + Approved bool `json:"approved"` + RejectionReason *string `json:"rejection_reason"` + ReviewedBy *uint64 `json:"reviewed_by"` + ReviewedAt *time.Time `json:"reviewed_at"` } type CustomIntegrationWithGuildCount struct { @@ -58,11 +63,15 @@ CREATE TABLE IF NOT EXISTS custom_integrations( PRIMARY KEY("id") ); CREATE INDEX IF NOT EXISTS custom_integrations_owner_id ON custom_integrations("owner_id"); + +ALTER TABLE custom_integrations ADD COLUMN IF NOT EXISTS "rejection_reason" TEXT DEFAULT NULL; +ALTER TABLE custom_integrations ADD COLUMN IF NOT EXISTS "reviewed_by" int8 DEFAULT NULL; +ALTER TABLE custom_integrations ADD COLUMN IF NOT EXISTS "reviewed_at" TIMESTAMP DEFAULT NULL; ` } func (i *CustomIntegrationTable) Get(ctx context.Context, id int) (CustomIntegration, bool, error) { - query := `SELECT "id", "owner_id", "webhook_url", "validation_url", "http_method", "name", "description", "image_url", "privacy_policy_url", "public", "approved" FROM custom_integrations WHERE "id" = $1;` + query := `SELECT "id", "owner_id", "webhook_url", "validation_url", "http_method", "name", "description", "image_url", "privacy_policy_url", "public", "approved", "rejection_reason", "reviewed_by", "reviewed_at" FROM custom_integrations WHERE "id" = $1;` var integration CustomIntegration err := i.QueryRow(ctx, query, id).Scan( @@ -77,6 +86,9 @@ func (i *CustomIntegrationTable) Get(ctx context.Context, id int) (CustomIntegra &integration.PrivacyPolicyUrl, &integration.Public, &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, ) if err != nil { @@ -92,7 +104,7 @@ func (i *CustomIntegrationTable) Get(ctx context.Context, id int) (CustomIntegra func (i *CustomIntegrationTable) GetAll(ctx context.Context, ids []int) ([]CustomIntegration, error) { query := ` -SELECT "id", "owner_id", "webhook_url", "validation_url", "http_method", "name", "description", "image_url", "privacy_policy_url", "public", "approved" +SELECT "id", "owner_id", "webhook_url", "validation_url", "http_method", "name", "description", "image_url", "privacy_policy_url", "public", "approved", "rejection_reason", "reviewed_by", "reviewed_at" FROM custom_integrations WHERE "id" = ANY($1);` @@ -121,6 +133,9 @@ WHERE "id" = ANY($1);` &integration.PrivacyPolicyUrl, &integration.Public, &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, ) if err != nil { @@ -153,6 +168,9 @@ SELECT integrations.privacy_policy_url, integrations.public, integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, COALESCE(counts.count, 0) AS guild_count FROM custom_integrations AS integrations LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id @@ -178,6 +196,127 @@ WHERE "owner_id" = $1;` &integration.PrivacyPolicyUrl, &integration.Public, &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, + &integration.GuildCount, + ) + + if err != nil { + return nil, err + } + + integrations = append(integrations, integration) + } + + return integrations, nil +} + +func (i *CustomIntegrationTable) GetPendingReview(ctx context.Context, limit, offset int) ([]CustomIntegrationWithGuildCount, error) { + query := ` +SELECT + integrations.id, + integrations.owner_id, + integrations.webhook_url, + integrations.validation_url, + integrations.http_method, + integrations.name, + integrations.description, + integrations.image_url, + integrations.privacy_policy_url, + integrations.public, + integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, + COALESCE(counts.count, 0) AS guild_count +FROM custom_integrations AS integrations +LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +WHERE integrations.public = TRUE AND integrations.approved = FALSE AND integrations.rejection_reason IS NULL +ORDER BY integrations.id DESC +LIMIT $1 OFFSET $2;` + + return i.queryListing(ctx, query, limit, offset) +} + +func (i *CustomIntegrationTable) GetApproved(ctx context.Context, limit, offset int) ([]CustomIntegrationWithGuildCount, error) { + query := ` +SELECT + integrations.id, + integrations.owner_id, + integrations.webhook_url, + integrations.validation_url, + integrations.http_method, + integrations.name, + integrations.description, + integrations.image_url, + integrations.privacy_policy_url, + integrations.public, + integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, + COALESCE(counts.count, 0) AS guild_count +FROM custom_integrations AS integrations +LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +WHERE integrations.approved = TRUE +ORDER BY integrations.id DESC +LIMIT $1 OFFSET $2;` + + return i.queryListing(ctx, query, limit, offset) +} + +func (i *CustomIntegrationTable) GetRejected(ctx context.Context, limit, offset int) ([]CustomIntegrationWithGuildCount, error) { + query := ` +SELECT + integrations.id, + integrations.owner_id, + integrations.webhook_url, + integrations.validation_url, + integrations.http_method, + integrations.name, + integrations.description, + integrations.image_url, + integrations.privacy_policy_url, + integrations.public, + integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, + COALESCE(counts.count, 0) AS guild_count +FROM custom_integrations AS integrations +LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +WHERE integrations.rejection_reason IS NOT NULL +ORDER BY integrations.reviewed_at DESC NULLS LAST +LIMIT $1 OFFSET $2;` + + return i.queryListing(ctx, query, limit, offset) +} + +func (i *CustomIntegrationTable) queryListing(ctx context.Context, query string, limit, offset int) ([]CustomIntegrationWithGuildCount, error) { + rows, err := i.Query(ctx, query, limit, offset) + if err != nil { + return nil, err + } + + integrations := make([]CustomIntegrationWithGuildCount, 0) + for rows.Next() { + var integration CustomIntegrationWithGuildCount + err := rows.Scan( + &integration.Id, + &integration.OwnerId, + &integration.WebhookUrl, + &integration.ValidationUrl, + &integration.HttpMethod, + &integration.Name, + &integration.Description, + &integration.ImageUrl, + &integration.PrivacyPolicyUrl, + &integration.Public, + &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, &integration.GuildCount, ) @@ -191,6 +330,27 @@ WHERE "owner_id" = $1;` return integrations, nil } +func (i *CustomIntegrationTable) CountByStatus(ctx context.Context, status string) (int, error) { + var query string + switch status { + case "pending": + query = `SELECT COUNT(*) FROM custom_integrations WHERE "public" = TRUE AND "approved" = FALSE AND "rejection_reason" IS NULL;` + case "approved": + query = `SELECT COUNT(*) FROM custom_integrations WHERE "approved" = TRUE;` + case "rejected": + query = `SELECT COUNT(*) FROM custom_integrations WHERE "rejection_reason" IS NOT NULL;` + default: + return 0, pgx.ErrNoRows + } + + var count int + if err := i.QueryRow(ctx, query).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} + func (i *CustomIntegrationGuildsTable) GetAvailableIntegrationsWithActive(ctx context.Context, guildId, userId uint64, limit, offset int) ([]CustomIntegrationWithActive, error) { query := ` WITH active AS ( @@ -210,12 +370,15 @@ SELECT integrations.privacy_policy_url, integrations.public, integrations.approved, + integrations.rejection_reason, + integrations.reviewed_by, + integrations.reviewed_at, COALESCE(counts.count, 0) AS guild_count, CASE WHEN active.integration_id IS NOT NULL THEN TRUE ELSE FALSE END AS added FROM custom_integrations as integrations LEFT OUTER JOIN active ON active.integration_id = integrations.id LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id -WHERE active.integration_id IS NOT NULL OR +WHERE active.integration_id IS NOT NULL OR ((integrations.public = 't' AND integrations.approved = 't') OR integrations.owner_id = $2) ORDER BY active.integration_id NULLS LAST, guild_count DESC LIMIT $3 OFFSET $4; @@ -241,6 +404,9 @@ LIMIT $3 OFFSET $4; &integration.PrivacyPolicyUrl, &integration.Public, &integration.Approved, + &integration.RejectionReason, + &integration.ReviewedBy, + &integration.ReviewedAt, &integration.GuildCount, &integration.Active, ) @@ -296,11 +462,56 @@ RETURNING "id"; } func (i *CustomIntegrationTable) SetPublic(ctx context.Context, integrationId int) (err error) { - query := `UPDATE custom_integrations SET "public" = TRUE WHERE "id" = $1;` + query := ` +UPDATE custom_integrations +SET "public" = TRUE, + "rejection_reason" = NULL, + "reviewed_by" = NULL, + "reviewed_at" = NULL +WHERE "id" = $1;` _, err = i.Exec(ctx, query, integrationId) return } +func (i *CustomIntegrationTable) Approve(ctx context.Context, integrationId int, reviewerId uint64) error { + query := ` +UPDATE custom_integrations +SET "approved" = TRUE, + "public" = TRUE, + "rejection_reason" = NULL, + "reviewed_by" = $2, + "reviewed_at" = NOW() +WHERE "id" = $1;` + _, err := i.Exec(ctx, query, integrationId, reviewerId) + return err +} + +func (i *CustomIntegrationTable) Reject(ctx context.Context, integrationId int, reviewerId uint64, reason string) error { + query := ` +UPDATE custom_integrations +SET "approved" = FALSE, + "public" = FALSE, + "rejection_reason" = $3, + "reviewed_by" = $2, + "reviewed_at" = NOW() +WHERE "id" = $1;` + _, err := i.Exec(ctx, query, integrationId, reviewerId, reason) + return err +} + +func (i *CustomIntegrationTable) Unapprove(ctx context.Context, integrationId int, reviewerId uint64) error { + query := ` +UPDATE custom_integrations +SET "approved" = FALSE, + "public" = FALSE, + "rejection_reason" = NULL, + "reviewed_by" = $2, + "reviewed_at" = NOW() +WHERE "id" = $1;` + _, err := i.Exec(ctx, query, integrationId, reviewerId) + return err +} + func (i *CustomIntegrationTable) Update(ctx context.Context, integration CustomIntegration) (err error) { query := ` UPDATE custom_integrations @@ -313,7 +524,10 @@ SET "image_url" = $7, "privacy_policy_url" = $8, "public" = $9, - "approved" = $10 + "approved" = $10, + "rejection_reason" = $11, + "reviewed_by" = $12, + "reviewed_at" = $13 WHERE "id" = $1; ` @@ -330,6 +544,9 @@ WHERE "id" = $1; integration.PrivacyPolicyUrl, integration.Public, integration.Approved, + integration.RejectionReason, + integration.ReviewedBy, + integration.ReviewedAt, ) return diff --git a/database.go b/database.go index f9b0a6b..2e3f7f8 100644 --- a/database.go +++ b/database.go @@ -50,6 +50,8 @@ type Database struct { Forms *FormsTable FormInputApiConfig *FormInputApiConfigTable FormInputApiHeaders *FormInputApiHeaderTable + GalleryListings *GalleryListingsTable + GalleryListingTags *GalleryListingTagsTable GdprLogs *GDPRLogsTable GlobalBlacklist *GlobalBlacklist GuildLeaveTime *GuildLeaveTime @@ -159,6 +161,8 @@ func NewDatabase(pool *pgxpool.Pool) *Database { FormInputApiConfig: newFormInputApiConfigTable(pool), FormInputApiHeaders: newFormInputApiHeaderTable(pool), FormInputOption: newFormInputOptionTable(pool), + GalleryListings: newGalleryListingsTable(pool), + GalleryListingTags: newGalleryListingTagsTable(pool), GdprLogs: newGDPRLogs(pool), GlobalBlacklist: newGlobalBlacklist(pool), GuildLeaveTime: newGuildLeaveTime(pool), @@ -344,6 +348,8 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.CategoryUpdateQueue, // Must be created after Tickets table d.TicketLabels, // Must be created after Tickets table d.TicketLabelAssignments, // Must be created after Tickets and TicketLabels tables + d.GalleryListings, // Gallery panel listings + d.GalleryListingTags, // Must be created after GalleryListings table d.FirstResponseTime, d.TicketMembers, d.TicketClaims, diff --git a/gallery_listing_tags.go b/gallery_listing_tags.go new file mode 100644 index 0000000..45a5c3f --- /dev/null +++ b/gallery_listing_tags.go @@ -0,0 +1,111 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type GalleryListingTagsTable struct { + *pgxpool.Pool +} + +func newGalleryListingTagsTable(pool *pgxpool.Pool) *GalleryListingTagsTable { + return &GalleryListingTagsTable{ + pool, + } +} + +func (t GalleryListingTagsTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS gallery_listing_tags ( + "listing_id" INT NOT NULL REFERENCES gallery_listings("id") ON DELETE CASCADE, + "tag" VARCHAR(30) NOT NULL, + PRIMARY KEY ("listing_id", "tag") +); +CREATE INDEX IF NOT EXISTS gallery_listing_tags_tag_idx ON gallery_listing_tags("tag"); +` +} + +// Set replaces all tags for a listing within a transaction. +func (t *GalleryListingTagsTable) Set(ctx context.Context, listingId int, tags []string) error { + tx, err := t.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + if err := t.deleteByListingWithTx(ctx, tx, listingId); err != nil { + return err + } + + for _, tag := range tags { + if err := t.insertWithTx(ctx, tx, listingId, tag); err != nil { + return err + } + } + + return tx.Commit(ctx) +} + +func (t *GalleryListingTagsTable) deleteByListingWithTx(ctx context.Context, tx pgx.Tx, listingId int) error { + query := `DELETE FROM gallery_listing_tags WHERE "listing_id" = $1;` + _, err := tx.Exec(ctx, query, listingId) + return err +} + +func (t *GalleryListingTagsTable) insertWithTx(ctx context.Context, tx pgx.Tx, listingId int, tag string) error { + query := `INSERT INTO gallery_listing_tags ("listing_id", "tag") VALUES ($1, $2) ON CONFLICT DO NOTHING;` + _, err := tx.Exec(ctx, query, listingId, tag) + return err +} + +// GetByListing returns all tags for a single listing. +func (t *GalleryListingTagsTable) GetByListing(ctx context.Context, listingId int) ([]string, error) { + query := `SELECT "tag" FROM gallery_listing_tags WHERE "listing_id" = $1 ORDER BY "tag" ASC;` + + rows, err := t.Query(ctx, query, listingId) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, err + } + tags = append(tags, tag) + } + + return tags, nil +} + +// GetByListings returns tags for multiple listings in a single query, keyed by listing ID. +func (t *GalleryListingTagsTable) GetByListings(ctx context.Context, listingIds []int) (map[int][]string, error) { + if len(listingIds) == 0 { + return make(map[int][]string), nil + } + + query := `SELECT "listing_id", "tag" FROM gallery_listing_tags WHERE "listing_id" = ANY($1) ORDER BY "listing_id", "tag" ASC;` + + rows, err := t.Query(ctx, query, listingIds) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[int][]string) + for rows.Next() { + var listingId int + var tag string + if err := rows.Scan(&listingId, &tag); err != nil { + return nil, err + } + result[listingId] = append(result[listingId], tag) + } + + return result, nil +} diff --git a/gallery_listings.go b/gallery_listings.go new file mode 100644 index 0000000..d5a9b60 --- /dev/null +++ b/gallery_listings.go @@ -0,0 +1,469 @@ +package database + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type GalleryListingStatus string + +const ( + GalleryListingStatusPending GalleryListingStatus = "pending" + GalleryListingStatusApproved GalleryListingStatus = "approved" + GalleryListingStatusRejected GalleryListingStatus = "rejected" +) + +type GalleryListing struct { + Id int `json:"id"` + SubmitterUserId uint64 `json:"submitter_user_id,string"` + SourceGuildId uint64 `json:"source_guild_id,string"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Status GalleryListingStatus `json:"status"` + ReviewNote *string `json:"review_note"` + ReviewedBy *uint64 `json:"reviewed_by,string"` + ReviewedAt *time.Time `json:"reviewed_at"` + ImportCount int `json:"import_count"` + Featured bool `json:"featured"` + // Visual design fields only — no operational settings + Title string `json:"title"` + Content string `json:"content"` + Colour int32 `json:"colour"` + ImageUrl *string `json:"image_url"` + ThumbnailUrl *string `json:"thumbnail_url"` + ButtonStyle *int16 `json:"button_style"` + ButtonLabel string `json:"button_label"` + EmojiName *string `json:"emoji_name"` + WelcomeMessage []byte `json:"welcome_message"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type GalleryListingsTable struct { + *pgxpool.Pool +} + +func newGalleryListingsTable(pool *pgxpool.Pool) *GalleryListingsTable { + return &GalleryListingsTable{ + pool, + } +} + +func (t GalleryListingsTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS gallery_listings ( + "id" SERIAL PRIMARY KEY, + "submitter_user_id" INT8 NOT NULL, + "source_guild_id" INT8 NOT NULL, + "name" VARCHAR(100) NOT NULL, + "description" TEXT NOT NULL, + "category" VARCHAR(50) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "review_note" TEXT, + "reviewed_by" INT8, + "reviewed_at" TIMESTAMPTZ, + "import_count" INT NOT NULL DEFAULT 0, + "featured" BOOL NOT NULL DEFAULT false, + "title" VARCHAR(255) NOT NULL, + "content" TEXT NOT NULL, + "colour" INT4 NOT NULL, + "image_url" TEXT, + "thumbnail_url" TEXT, + "button_style" INT2 DEFAULT 1, + "button_label" VARCHAR(80) NOT NULL, + "emoji_name" VARCHAR(32), + "welcome_message" JSONB, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS gallery_listings_status_idx ON gallery_listings("status"); +CREATE INDEX IF NOT EXISTS gallery_listings_category_idx ON gallery_listings("category"); +CREATE INDEX IF NOT EXISTS gallery_listings_submitter_user_id_idx ON gallery_listings("submitter_user_id"); +CREATE INDEX IF NOT EXISTS gallery_listings_source_guild_id_idx ON gallery_listings("source_guild_id"); +CREATE INDEX IF NOT EXISTS gallery_listings_featured_idx ON gallery_listings("featured") WHERE "featured" = true; +CREATE INDEX IF NOT EXISTS gallery_listings_import_count_idx ON gallery_listings("import_count" DESC); +CREATE INDEX IF NOT EXISTS gallery_listings_created_at_idx ON gallery_listings("created_at" DESC); +` +} + +func (l *GalleryListing) fieldPtrs() []interface{} { + return []interface{}{ + &l.Id, + &l.SubmitterUserId, + &l.SourceGuildId, + &l.Name, + &l.Description, + &l.Category, + &l.Status, + &l.ReviewNote, + &l.ReviewedBy, + &l.ReviewedAt, + &l.ImportCount, + &l.Featured, + &l.Title, + &l.Content, + &l.Colour, + &l.ImageUrl, + &l.ThumbnailUrl, + &l.ButtonStyle, + &l.ButtonLabel, + &l.EmojiName, + &l.WelcomeMessage, + &l.CreatedAt, + &l.UpdatedAt, + } +} + +const galleryListingSelectColumns = ` + "id", + "submitter_user_id", + "source_guild_id", + "name", + "description", + "category", + "status", + "review_note", + "reviewed_by", + "reviewed_at", + "import_count", + "featured", + "title", + "content", + "colour", + "image_url", + "thumbnail_url", + "button_style", + "button_label", + "emoji_name", + "welcome_message", + "created_at", + "updated_at" +` + +func (t *GalleryListingsTable) Create(ctx context.Context, listing GalleryListing) (int, error) { + query := ` +INSERT INTO gallery_listings ( + "submitter_user_id", + "source_guild_id", + "name", + "description", + "category", + "status", + "title", + "content", + "colour", + "image_url", + "thumbnail_url", + "button_style", + "button_label", + "emoji_name", + "welcome_message" +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) +RETURNING "id";` + + var id int + err := t.QueryRow(ctx, query, + listing.SubmitterUserId, + listing.SourceGuildId, + listing.Name, + listing.Description, + listing.Category, + listing.Status, + listing.Title, + listing.Content, + listing.Colour, + listing.ImageUrl, + listing.ThumbnailUrl, + listing.ButtonStyle, + listing.ButtonLabel, + listing.EmojiName, + listing.WelcomeMessage, + ).Scan(&id) + + return id, err +} + +func (t *GalleryListingsTable) GetById(ctx context.Context, id int) (listing GalleryListing, ok bool, e error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "id" = $1;` + + switch err := t.QueryRow(ctx, query, id).Scan(listing.fieldPtrs()...); err { + case nil: + ok = true + case pgx.ErrNoRows: + default: + e = err + } + + return +} + +// GetApproved returns paginated approved listings with optional filters. +// Sort must be "popular" (import_count DESC) or "recent" (created_at DESC). +// Search matches name or description using case-insensitive ILIKE. +func (t *GalleryListingsTable) GetApproved(ctx context.Context, category string, tag string, search string, sort string, page int, pageSize int) ([]GalleryListing, int, error) { + var conditions []string + var args []interface{} + + conditions = append(conditions, `"status" = 'approved'`) + + if category != "" { + args = append(args, category) + conditions = append(conditions, fmt.Sprintf(`"category" = $%d`, len(args))) + } + + if tag != "" { + args = append(args, tag) + conditions = append(conditions, fmt.Sprintf(`"id" IN (SELECT "listing_id" FROM gallery_listing_tags WHERE "tag" = $%d)`, len(args))) + } + + if search != "" { + args = append(args, "%"+search+"%") + argIdx := len(args) + conditions = append(conditions, fmt.Sprintf(`("name" ILIKE $%d OR "description" ILIKE $%d)`, argIdx, argIdx)) + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = " WHERE " + strings.Join(conditions, " AND ") + } + + // Count total matching records + countQuery := "SELECT COUNT(*) FROM gallery_listings" + whereClause + var total int + if err := t.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + + // Determine sort order + orderClause := ` ORDER BY "created_at" DESC` + if sort == "popular" { + orderClause = ` ORDER BY "import_count" DESC, "created_at" DESC` + } + + // Pagination + offset := (page - 1) * pageSize + args = append(args, pageSize) + limitClause := fmt.Sprintf(` LIMIT $%d`, len(args)) + args = append(args, offset) + offsetClause := fmt.Sprintf(` OFFSET $%d`, len(args)) + + query := "SELECT " + galleryListingSelectColumns + " FROM gallery_listings" + whereClause + orderClause + limitClause + offsetClause + + rows, err := t.Query(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, 0, err + } + listings = append(listings, listing) + } + + return listings, total, nil +} + +// GetFeatured returns all approved featured listings. +func (t *GalleryListingsTable) GetFeatured(ctx context.Context) ([]GalleryListing, error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "status" = 'approved' AND "featured" = true ORDER BY "import_count" DESC;` + + rows, err := t.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, err + } + listings = append(listings, listing) + } + + return listings, nil +} + +// GetBySubmitter returns all listings submitted by a given user. +func (t *GalleryListingsTable) GetBySubmitter(ctx context.Context, userId uint64) ([]GalleryListing, error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "submitter_user_id" = $1 ORDER BY "created_at" DESC;` + + rows, err := t.Query(ctx, query, userId) + if err != nil { + return nil, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, err + } + listings = append(listings, listing) + } + + return listings, nil +} + +// GetByGuild returns all listings from a given guild. +func (t *GalleryListingsTable) GetByGuild(ctx context.Context, guildId uint64) ([]GalleryListing, error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "source_guild_id" = $1 ORDER BY "created_at" DESC;` + + rows, err := t.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, err + } + listings = append(listings, listing) + } + + return listings, nil +} + +// GetPending returns all listings with pending status for admin review. +func (t *GalleryListingsTable) GetPending(ctx context.Context) ([]GalleryListing, error) { + return t.GetByStatus(ctx, GalleryListingStatusPending) +} + +// GetByStatus returns all listings filtered by status. +func (t *GalleryListingsTable) GetByStatus(ctx context.Context, status GalleryListingStatus) ([]GalleryListing, error) { + query := `SELECT ` + galleryListingSelectColumns + ` FROM gallery_listings WHERE "status" = $1 ORDER BY "created_at" DESC;` + + rows, err := t.Query(ctx, query, status) + if err != nil { + return nil, err + } + defer rows.Close() + + var listings []GalleryListing + for rows.Next() { + var listing GalleryListing + if err := rows.Scan(listing.fieldPtrs()...); err != nil { + return nil, err + } + listings = append(listings, listing) + } + + return listings, nil +} + +// UpdateStatus updates the listing status along with reviewer information. +func (t *GalleryListingsTable) UpdateStatus(ctx context.Context, id int, status GalleryListingStatus, reviewedBy uint64, reviewNote *string) error { + query := ` +UPDATE gallery_listings +SET "status" = $2, "reviewed_by" = $3, "reviewed_at" = NOW(), "review_note" = $4, "updated_at" = NOW() +WHERE "id" = $1;` + + _, err := t.Exec(ctx, query, id, status, reviewedBy, reviewNote) + return err +} + +// Update updates mutable fields on a gallery listing (category, featured). +func (t *GalleryListingsTable) Update(ctx context.Context, id int, category *string, featured *bool) error { + var setClauses []string + var args []interface{} + args = append(args, id) // $1 is always the id + + if category != nil { + args = append(args, *category) + setClauses = append(setClauses, fmt.Sprintf(`"category" = $%d`, len(args))) + } + + if featured != nil { + args = append(args, *featured) + setClauses = append(setClauses, fmt.Sprintf(`"featured" = $%d`, len(args))) + } + + if len(setClauses) == 0 { + return nil + } + + setClauses = append(setClauses, `"updated_at" = NOW()`) + + query := `UPDATE gallery_listings SET ` + strings.Join(setClauses, ", ") + ` WHERE "id" = $1;` + _, err := t.Exec(ctx, query, args...) + return err +} + +// IncrementImportCount atomically increments the import count for a listing. +func (t *GalleryListingsTable) IncrementImportCount(ctx context.Context, id int) error { + query := `UPDATE gallery_listings SET "import_count" = "import_count" + 1, "updated_at" = NOW() WHERE "id" = $1;` + _, err := t.Exec(ctx, query, id) + return err +} + +// UpdateMetadata updates the name, description, and category of a listing. +func (t *GalleryListingsTable) UpdateMetadata(ctx context.Context, id int, name string, description string, category string) error { + query := `UPDATE gallery_listings SET "name" = $2, "description" = $3, "category" = $4, "updated_at" = NOW() WHERE "id" = $1;` + _, err := t.Exec(ctx, query, id, name, description, category) + return err +} + +// Resubmit replaces a listing's panel snapshot and metadata, resetting it for review. +func (t *GalleryListingsTable) Resubmit(ctx context.Context, listing GalleryListing) error { + query := ` +UPDATE gallery_listings SET + "name" = $2, + "description" = $3, + "category" = $4, + "status" = $5, + "review_note" = NULL, + "reviewed_by" = NULL, + "reviewed_at" = NULL, + "title" = $6, + "content" = $7, + "colour" = $8, + "image_url" = $9, + "thumbnail_url" = $10, + "button_style" = $11, + "button_label" = $12, + "emoji_name" = $13, + "welcome_message" = $14, + "updated_at" = NOW() +WHERE "id" = $1;` + + _, err := t.Exec(ctx, query, + listing.Id, + listing.Name, + listing.Description, + listing.Category, + listing.Status, + listing.Title, + listing.Content, + listing.Colour, + listing.ImageUrl, + listing.ThumbnailUrl, + listing.ButtonStyle, + listing.ButtonLabel, + listing.EmojiName, + listing.WelcomeMessage, + ) + return err +} + +// Delete removes a gallery listing by ID. +func (t *GalleryListingsTable) Delete(ctx context.Context, id int) error { + query := `DELETE FROM gallery_listings WHERE "id" = $1;` + _, err := t.Exec(ctx, query, id) + return err +} From 6975703876ce8a7ecdf5e9d4dd02b531b14fbed2 Mon Sep 17 00:00:00 2001 From: biast12 Date: Thu, 23 Apr 2026 20:08:58 +0200 Subject: [PATCH 09/49] Remove global panel settings Introduce Panel.ShowInOpenCommand and persist it: add the show_in_open_command column to the panels table, include it in SELECTs, INSERT/UPDATE parameter lists, scans and field pointers, and expose it in multi-panel target queries. Remove the unused ArchiveChannel.GetByPanel method. Simplify and trim Settings by removing several deprecated fields and database columns (hide_claim_button, disable_open_command, use_threads, ticket_notification_channel, exit_survey_form_id, hide_close_button, hide_close_with_reason_button) along with their helper methods; update the settings schema, default values, and Get/Set queries/parameters to match the reduced settings surface. General cleanup to align code and DB schema with current features. --- archivechannel.go | 17 ------ multipaneltargets.go | 1 + panels.go | 31 +++++++--- settings.go | 141 ++++++------------------------------------- 4 files changed, 44 insertions(+), 146 deletions(-) diff --git a/archivechannel.go b/archivechannel.go index c4f2132..75aae2c 100644 --- a/archivechannel.go +++ b/archivechannel.go @@ -36,23 +36,6 @@ func (c *ArchiveChannel) Get(ctx context.Context, guildId uint64) (archiveChanne return } -func (c *ArchiveChannel) GetByPanel(ctx context.Context, guildId uint64, panelId int) (archiveChannel *uint64, e error) { - query := ` - SELECT - COALESCE(p.transcript_channel_id, ac.channel_id) - FROM - panels p - JOIN archive_channel ac ON ac.guild_id = p.guild_id - WHERE p.panel_id = $1 AND p.guild_id = $2; -` - - if err := c.QueryRow(ctx, query, panelId, guildId).Scan(&archiveChannel); err != nil && err != pgx.ErrNoRows { - e = err - } - - return -} - func (c *ArchiveChannel) Set(ctx context.Context, guildId uint64, archiveChannel *uint64) (err error) { query := ` INSERT INTO archive_channel("guild_id", "channel_id") diff --git a/multipaneltargets.go b/multipaneltargets.go index 43f869c..89b6dd3 100644 --- a/multipaneltargets.go +++ b/multipaneltargets.go @@ -78,6 +78,7 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, + panels.show_in_open_command, multi_panel_targets.custom_label, multi_panel_targets.description, multi_panel_targets.custom_emoji_name, diff --git a/panels.go b/panels.go index 0e866da..1581989 100644 --- a/panels.go +++ b/panels.go @@ -41,6 +41,7 @@ type Panel struct { HideCloseButton bool `json:"hide_close_button"` HideCloseWithReasonButton bool `json:"hide_close_with_reason_button"` HideClaimButton bool `json:"hide_claim_button"` + ShowInOpenCommand bool `json:"show_in_open_command"` } type PanelWithWelcomeMessage struct { @@ -94,6 +95,7 @@ CREATE TABLE IF NOT EXISTS panels( "hide_close_button" bool NOT NULL DEFAULT false, "hide_close_with_reason_button" bool NOT NULL DEFAULT false, "hide_claim_button" bool NOT NULL DEFAULT false, + "show_in_open_command" bool NOT NULL DEFAULT false, FOREIGN KEY ("welcome_message") REFERENCES embeds("id") ON DELETE SET NULL, FOREIGN KEY ("form_id") REFERENCES forms("form_id"), FOREIGN KEY ("exit_survey_form_id") REFERENCES forms("form_id"), @@ -140,7 +142,8 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command FROM panels WHERE "message_id" = $1; ` @@ -187,7 +190,8 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command FROM panels WHERE "panel_id" = $1; ` @@ -235,6 +239,7 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, + panels.show_in_open_command, embeds.id, embeds.guild_id, embeds.title, @@ -343,7 +348,8 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command FROM panels WHERE "guild_id" = $1 AND "custom_id" = $2; ` @@ -393,7 +399,8 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command FROM panels WHERE "guild_id" = $1 AND "form_id" = $2; ` @@ -444,6 +451,7 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, + panels.show_in_open_command FROM panels INNER JOIN forms ON forms.form_id = panels.form_id @@ -495,7 +503,8 @@ SELECT ticket_limit, hide_close_button, hide_close_with_reason_button, - hide_claim_button + hide_claim_button, + show_in_open_command FROM panels WHERE "guild_id" = $1 ORDER BY "panel_id" ASC;` @@ -553,6 +562,7 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, + panels.show_in_open_command, embeds.id, embeds.guild_id, embeds.title, @@ -678,9 +688,10 @@ INSERT INTO panels( "ticket_limit", "hide_close_button", "hide_close_with_reason_button", - "hide_claim_button" + "hide_claim_button", + "show_in_open_command" ) -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31) +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32) ON CONFLICT("message_id") DO NOTHING RETURNING "panel_id";` @@ -716,6 +727,7 @@ RETURNING "panel_id";` panel.HideCloseButton, panel.HideCloseWithReasonButton, panel.HideClaimButton, + panel.ShowInOpenCommand, ).Scan(&panelId) return @@ -768,7 +780,8 @@ UPDATE panels "ticket_limit" = $28, "hide_close_button" = $29, "hide_close_with_reason_button" = $30, - "hide_claim_button" = $31 + "hide_claim_button" = $31, + "show_in_open_command" = $32 WHERE "panel_id" = $1 ;` @@ -805,6 +818,7 @@ UPDATE panels panel.HideCloseButton, panel.HideCloseWithReasonButton, panel.HideClaimButton, + panel.ShowInOpenCommand, ) return err @@ -928,5 +942,6 @@ func (p *Panel) fieldPtrs() []interface{} { &p.HideCloseButton, &p.HideCloseWithReasonButton, &p.HideClaimButton, + &p.ShowInOpenCommand, } } diff --git a/settings.go b/settings.go index 213568b..c074372 100644 --- a/settings.go +++ b/settings.go @@ -6,41 +6,27 @@ import ( "github.com/jackc/pgx/v4/pgxpool" ) -// TODO: Migrate all settings to this table type Settings struct { - HideClaimButton bool `json:"hide_claim_button"` - DisableOpenCommand bool `json:"disable_open_command"` ContextMenuPermissionLevel int `json:"context_menu_permission_level,string"` ContextMenuAddSender bool `json:"context_menu_add_sender"` ContextMenuPanel *int `json:"context_menu_panel"` StoreTranscripts bool `json:"store_transcripts"` - UseThreads bool `json:"use_threads"` - TicketNotificationChannel *uint64 `json:"ticket_notification_channel,string"` ThreadArchiveDuration int `json:"thread_archive_duration"` OverflowEnabled bool `json:"overflow_enabled"` OverflowCategoryId *uint64 `json:"overflow_category_id,string"` // If overflow_enabled and nil, use root - ExitSurveyFormId *uint64 `json:"exit_survey_form_id,string"` AnonymiseDashboardResponses bool `json:"anonymise_dashboard_responses"` - HideCloseButton bool `json:"hide_close_button"` - HideCloseWithReasonButton bool `json:"hide_close_with_reason_button"` } func defaultSettings() Settings { return Settings{ - HideClaimButton: false, - DisableOpenCommand: false, - ContextMenuPermissionLevel: 0, - ContextMenuAddSender: true, - ContextMenuPanel: nil, - StoreTranscripts: true, - UseThreads: false, - TicketNotificationChannel: nil, - ThreadArchiveDuration: 10080, - OverflowEnabled: false, - OverflowCategoryId: nil, - ExitSurveyFormId: nil, - HideCloseButton: false, - HideCloseWithReasonButton: false, + ContextMenuPermissionLevel: 0, + ContextMenuAddSender: true, + ContextMenuPanel: nil, + StoreTranscripts: true, + ThreadArchiveDuration: 10080, + OverflowEnabled: false, + OverflowCategoryId: nil, + AnonymiseDashboardResponses: false, } } @@ -58,25 +44,16 @@ func (s SettingsTable) Schema() string { return ` CREATE TABLE IF NOT EXISTS settings( "guild_id" int8 NOT NULL, - "hide_claim_button" bool DEFAULT 'f', - "disable_open_command" bool DEFAULT 'f', "context_menu_permission_level" int DEFAULT '0', "context_menu_add_sender" bool DEFAULT 't', "context_menu_panel" int DEFAULT NULL, "store_transcripts" bool DEFAULT 't', - "use_threads" bool DEFAULT 'f', - "ticket_notification_channel" int8 DEFAULT NULL, "thread_archive_duration" int DEFAULT '10080', "overflow_enabled" bool DEFAULT 'f', "overflow_category_id" int8 DEFAULT NULL, - "exit_survey_form_id" int4 DEFAULT NULL, "anonymise_dashboard_responses" bool DEFAULT 'f', - "hide_close_button" bool DEFAULT 'f', - "hide_close_with_reason_button" bool DEFAULT 'f', FOREIGN KEY("context_menu_panel") REFERENCES panels("panel_id") ON DELETE SET NULL, - FOREIGN KEY("exit_survey_form_id") REFERENCES forms("form_id") ON DELETE SET NULL, - PRIMARY KEY("guild_id"), - CHECK (use_threads = false OR ticket_notification_channel IS NOT NULL) + PRIMARY KEY("guild_id") ); ` } @@ -84,40 +61,28 @@ CREATE TABLE IF NOT EXISTS settings( func (s *SettingsTable) Get(ctx context.Context, guildId uint64) (Settings, error) { query := ` SELECT - "hide_claim_button", - "disable_open_command", "context_menu_permission_level", "context_menu_add_sender", "context_menu_panel", "store_transcripts", - "use_threads", - "ticket_notification_channel", "thread_archive_duration", "overflow_enabled", "overflow_category_id", - "anonymise_dashboard_responses", - "hide_close_button", - "hide_close_with_reason_button" + "anonymise_dashboard_responses" FROM settings WHERE "guild_id" = $1; ` var settings Settings err := s.QueryRow(ctx, query, guildId).Scan( - &settings.HideClaimButton, - &settings.DisableOpenCommand, &settings.ContextMenuPermissionLevel, &settings.ContextMenuAddSender, &settings.ContextMenuPanel, &settings.StoreTranscripts, - &settings.UseThreads, - &settings.TicketNotificationChannel, &settings.ThreadArchiveDuration, &settings.OverflowEnabled, &settings.OverflowCategoryId, &settings.AnonymiseDashboardResponses, - &settings.HideCloseButton, - &settings.HideCloseWithReasonButton, ) if err == nil { @@ -133,86 +98,44 @@ func (s *SettingsTable) Set(ctx context.Context, guildId uint64, settings Settin query := ` INSERT INTO settings( "guild_id", - "hide_claim_button", - "disable_open_command", "context_menu_permission_level", "context_menu_add_sender", "context_menu_panel", "store_transcripts", - "use_threads", - "ticket_notification_channel", "thread_archive_duration", "overflow_enabled", "overflow_category_id", - "anonymise_dashboard_responses", - "hide_close_button", - "hide_close_with_reason_button" + "anonymise_dashboard_responses" ) -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT("guild_id") DO UPDATE SET - "hide_claim_button" = $2, - "disable_open_command" = $3, - "context_menu_permission_level" = $4, - "context_menu_add_sender" = $5, - "context_menu_panel" = $6, - "store_transcripts" = $7, - "use_threads" = $8, - "ticket_notification_channel" = $9, - "thread_archive_duration" = $10, - "overflow_enabled" = $11, - "overflow_category_id" = $12, - "anonymise_dashboard_responses" = $13, - "hide_close_button" = $14, - "hide_close_with_reason_button" = $15 + "context_menu_permission_level" = $2, + "context_menu_add_sender" = $3, + "context_menu_panel" = $4, + "store_transcripts" = $5, + "thread_archive_duration" = $6, + "overflow_enabled" = $7, + "overflow_category_id" = $8, + "anonymise_dashboard_responses" = $9 ; ` _, err = s.Exec(ctx, query, guildId, - settings.HideClaimButton, - settings.DisableOpenCommand, settings.ContextMenuPermissionLevel, settings.ContextMenuAddSender, settings.ContextMenuPanel, settings.StoreTranscripts, - settings.UseThreads, - settings.TicketNotificationChannel, settings.ThreadArchiveDuration, settings.OverflowEnabled, settings.OverflowCategoryId, settings.AnonymiseDashboardResponses, - settings.HideCloseButton, - settings.HideCloseWithReasonButton, ) return } -func (s *SettingsTable) SetHideClaimButton(ctx context.Context, guildId uint64, hideClaimButton bool) (err error) { - query := ` -INSERT INTO settings("guild_id", "hide_claim_button") -VALUES($1, $2) -ON CONFLICT("guild_id") -DO UPDATE SET "hide_claim_button" = $2; -` - - _, err = s.Exec(ctx, query, guildId, hideClaimButton) - return -} - -func (s *SettingsTable) SetDisableOpenCommand(ctx context.Context, guildId uint64, disableOpenCommand bool) (err error) { - query := ` -INSERT INTO settings("guild_id", "disable_open_command") -VALUES($1, $2) -ON CONFLICT("guild_id") -DO UPDATE SET "disable_open_command" = $2; -` - - _, err = s.Exec(ctx, query, guildId, disableOpenCommand) - return -} - func (s *SettingsTable) SetContextMenuPermissionLevel(ctx context.Context, guildId uint64, permissionLevel int) (err error) { query := ` INSERT INTO settings("guild_id", "context_menu_permission_level") @@ -236,27 +159,3 @@ DO UPDATE SET "overflow_enabled" = $2, "overflow_category_id" = $3; _, err = s.Exec(ctx, query, guildId, enabled, categoryId) return } - -func (s *SettingsTable) EnableThreads(ctx context.Context, guildId uint64, ticketNotificationChannel uint64) (err error) { - query := ` -INSERT INTO settings("guild_id", "use_threads", "ticket_notification_channel") -VALUES($1, true, $2) -ON CONFLICT("guild_id") -DO UPDATE SET "use_threads" = true, "ticket_notification_channel" = $2; -` - - _, err = s.Exec(ctx, query, guildId, ticketNotificationChannel) - return -} - -func (s *SettingsTable) DisableThreads(ctx context.Context, guildId uint64) (err error) { - query := ` -INSERT INTO settings("guild_id", "use_threads", "ticket_notification_channel") -VALUES($1, false, NULL) -ON CONFLICT("guild_id") -DO UPDATE SET "use_threads" = false, "ticket_notification_channel" = NULL; -` - - _, err = s.Exec(ctx, query, guildId) - return -} From bf2981d36e002d76e098d291b81fb660e933d09c Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 26 Apr 2026 15:26:38 +0100 Subject: [PATCH 10/49] analytics Signed-off-by: Ben --- analyticstypes.go | 14 ++++++++++ closereason.go | 66 ++++++++++++++++++++++++++++++++++++++++++++ firstresponsetime.go | 22 +++++++++++++++ serviceratings.go | 2 -- tickets.go | 56 +++++++++++++++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 analyticstypes.go diff --git a/analyticstypes.go b/analyticstypes.go new file mode 100644 index 0000000..044ea37 --- /dev/null +++ b/analyticstypes.go @@ -0,0 +1,14 @@ +package database + +import "time" + +type TripleWindow struct { + AllTime *time.Duration + Monthly *time.Duration + Weekly *time.Duration +} + +type CountOnDate struct { + Date time.Time `json:"date"` + Count uint64 `json:"count"` +} diff --git a/closereason.go b/closereason.go index 0731dc4..1780a0d 100644 --- a/closereason.go +++ b/closereason.go @@ -118,3 +118,69 @@ func (c *CloseMetadataTable) Delete(ctx context.Context, guildId uint64, ticketI _, err = c.Exec(ctx, query, guildId, ticketId) return } + +func (c *CloseMetadataTable) GetTopCloseReasons(ctx context.Context, guildId uint64, panelId *int, limit int) ([]string, error) { + query := ` +SELECT cr.close_reason +FROM close_reason cr +INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id +WHERE cr.guild_id = $1 + AND ($2::int IS NULL OR t.panel_id = $2) + AND cr.close_reason IS NOT NULL + AND cr.close_reason != '' + AND cr.close_reason != 'Automatically closed due to inactivity' +GROUP BY cr.close_reason +ORDER BY COUNT(*) DESC +LIMIT $3;` + + rows, err := c.Query(ctx, query, guildId, panelId, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var reasons []string + for rows.Next() { + var reason string + if err := rows.Scan(&reason); err != nil { + return nil, err + } + reasons = append(reasons, reason) + } + + return reasons, nil +} + +func (c *CloseMetadataTable) GetTopCloseReasonsContaining(ctx context.Context, guildId uint64, panelId *int, contains string, limit int) ([]string, error) { + query := ` +SELECT cr.close_reason +FROM close_reason cr +INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id +WHERE cr.guild_id = $1 + AND ($2::int IS NULL OR t.panel_id = $2) + AND cr.close_reason IS NOT NULL + AND cr.close_reason != '' + AND cr.close_reason != 'Automatically closed due to inactivity' + AND LOWER(cr.close_reason) LIKE '%' || LOWER($3) || '%' +GROUP BY cr.close_reason +ORDER BY CASE WHEN LOWER(cr.close_reason) LIKE LOWER($3) || '%' THEN 0 ELSE 1 END, + COUNT(*) DESC +LIMIT $4;` + + rows, err := c.Query(ctx, query, guildId, panelId, contains, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var reasons []string + for rows.Next() { + var reason string + if err := rows.Scan(&reason); err != nil { + return nil, err + } + reasons = append(reasons, reason) + } + + return reasons, nil +} diff --git a/firstresponsetime.go b/firstresponsetime.go index 5f72561..da8a5dd 100644 --- a/firstresponsetime.go +++ b/firstresponsetime.go @@ -100,6 +100,28 @@ func (f *FirstResponseTime) GetAverageAllTimeUser(ctx context.Context, guildId, return } +func (f *FirstResponseTime) GetAverageTripleWindow(ctx context.Context, guildId uint64) (TripleWindow, error) { + query := ` +SELECT + AVG(frt.response_time), + AVG(frt.response_time) FILTER (WHERE t.open_time > NOW() - INTERVAL '30 days'), + AVG(frt.response_time) FILTER (WHERE t.open_time > NOW() - INTERVAL '7 days') +FROM first_response_time frt +INNER JOIN tickets t ON frt.guild_id = t.guild_id AND frt.ticket_id = t.id +WHERE frt.guild_id = $1;` + + var allTime, monthly, weekly *time.Duration + if err := f.QueryRow(ctx, query, guildId).Scan(&allTime, &monthly, &weekly); err != nil { + return TripleWindow{}, err + } + + return TripleWindow{ + AllTime: allTime, + Monthly: monthly, + Weekly: weekly, + }, nil +} + func (f *FirstResponseTime) Set(ctx context.Context, guildId, userId uint64, ticketId int, responseTime time.Duration) (err error) { query := `INSERT INTO first_response_time("guild_id", "ticket_id", "user_id", "response_time") VALUES($1, $2, $3, $4) ON CONFLICT("guild_id", "ticket_id") DO NOTHING;` _, err = f.Exec(ctx, query, guildId, ticketId, userId, responseTime) diff --git a/serviceratings.go b/serviceratings.go index 3607c67..1043ab9 100644 --- a/serviceratings.go +++ b/serviceratings.go @@ -61,7 +61,6 @@ WHERE service_ratings.guild_id = $1 AND ticket_claims.user_id = $2; return } -// TODO: Materialized view? func (r *ServiceRatings) GetAverage(ctx context.Context, guildId uint64) (average float32, err error) { // Returns NULL if no ratings var f *float32 @@ -75,7 +74,6 @@ func (r *ServiceRatings) GetAverage(ctx context.Context, guildId uint64) (averag return } -// TODO: Materialized view? func (r *ServiceRatings) GetAverageClaimedBy(ctx context.Context, guildId, userId uint64) (average float32, err error) { // Returns NULL if no tickets claimed var f *float32 diff --git a/tickets.go b/tickets.go index 90f0bd4..d549cff 100644 --- a/tickets.go +++ b/tickets.go @@ -104,6 +104,7 @@ CREATE TABLE guild_ticket_counters ( ); CREATE INDEX IF NOT EXISTS tickets_channel_id ON tickets("channel_id"); CREATE INDEX IF NOT EXISTS tickets_panel_id ON tickets("panel_id"); +CREATE INDEX IF NOT EXISTS tickets_guild_id_open_time ON tickets("guild_id", "open_time"); ` } @@ -1357,3 +1358,58 @@ SET last_ticket_id = EXCLUDED.last_ticket_id;` _, err = t.Exec(ctx, query, guildId) return } + +func (t *TicketTable) GetTicketsPerDay(ctx context.Context, guildId uint64, nDays int) ([]CountOnDate, error) { + query := ` +SELECT d::date AS date, COUNT(t.id) AS count +FROM generate_series(CURRENT_DATE - ($2 - 1) * INTERVAL '1 day', CURRENT_DATE, '1 day') AS d +LEFT JOIN tickets t ON t.guild_id = $1 AND date_trunc('day', t.open_time AT TIME ZONE 'UTC') = d::date +GROUP BY d::date +ORDER BY d::date DESC;` + + rows, err := t.Query(ctx, query, guildId, nDays) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make([]CountOnDate, 0, nDays) + for rows.Next() { + var c CountOnDate + if err := rows.Scan(&c.Date, &c.Count); err != nil { + return nil, err + } + counts = append(counts, c) + } + + return counts, nil +} + +func (t *TicketTable) GetTicketDurationTripleWindow(ctx context.Context, guildId uint64) (TripleWindow, error) { + query := ` +SELECT + AVG(EXTRACT(EPOCH FROM (close_time - open_time))) FILTER (WHERE close_time IS NOT NULL), + AVG(EXTRACT(EPOCH FROM (close_time - open_time))) FILTER (WHERE close_time IS NOT NULL AND close_time > NOW() - INTERVAL '30 days'), + AVG(EXTRACT(EPOCH FROM (close_time - open_time))) FILTER (WHERE close_time IS NOT NULL AND close_time > NOW() - INTERVAL '7 days') +FROM tickets +WHERE guild_id = $1;` + + var allTimeSecs, monthlySecs, weeklySecs *float64 + if err := t.QueryRow(ctx, query, guildId).Scan(&allTimeSecs, &monthlySecs, &weeklySecs); err != nil { + return TripleWindow{}, err + } + + return TripleWindow{ + AllTime: secondsToDuration(allTimeSecs), + Monthly: secondsToDuration(monthlySecs), + Weekly: secondsToDuration(weeklySecs), + }, nil +} + +func secondsToDuration(secs *float64) *time.Duration { + if secs == nil { + return nil + } + d := time.Duration(*secs * float64(time.Second)) + return &d +} From 14f2c8e2bcea93a12e3a86155f9fb660e524e2ff Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 26 Apr 2026 17:38:08 +0100 Subject: [PATCH 11/49] +g support for t&f Signed-off-by: Ben --- gallery_listings.go | 66 ++++++++++++++++++++++++++++++++------------ gallery_snapshots.go | 43 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 gallery_snapshots.go diff --git a/gallery_listings.go b/gallery_listings.go index d5a9b60..c4b4727 100644 --- a/gallery_listings.go +++ b/gallery_listings.go @@ -18,10 +18,17 @@ const ( GalleryListingStatusRejected GalleryListingStatus = "rejected" ) +const ( + GalleryListingTypePanel = "panel" + GalleryListingTypeTag = "tag" + GalleryListingTypeForm = "form" +) + type GalleryListing struct { Id int `json:"id"` SubmitterUserId uint64 `json:"submitter_user_id,string"` SourceGuildId uint64 `json:"source_guild_id,string"` + ListingType string `json:"listing_type"` Name string `json:"name"` Description string `json:"description"` Category string `json:"category"` @@ -31,7 +38,8 @@ type GalleryListing struct { ReviewedAt *time.Time `json:"reviewed_at"` ImportCount int `json:"import_count"` Featured bool `json:"featured"` - // Visual design fields only — no operational settings + SnapshotData []byte `json:"snapshot_data"` + // Panel visual design fields — only populated for panel listings Title string `json:"title"` Content string `json:"content"` Colour int32 `json:"colour"` @@ -61,6 +69,7 @@ CREATE TABLE IF NOT EXISTS gallery_listings ( "id" SERIAL PRIMARY KEY, "submitter_user_id" INT8 NOT NULL, "source_guild_id" INT8 NOT NULL, + "listing_type" VARCHAR(20) NOT NULL DEFAULT 'panel', "name" VARCHAR(100) NOT NULL, "description" TEXT NOT NULL, "category" VARCHAR(50) NOT NULL, @@ -70,13 +79,14 @@ CREATE TABLE IF NOT EXISTS gallery_listings ( "reviewed_at" TIMESTAMPTZ, "import_count" INT NOT NULL DEFAULT 0, "featured" BOOL NOT NULL DEFAULT false, - "title" VARCHAR(255) NOT NULL, - "content" TEXT NOT NULL, - "colour" INT4 NOT NULL, + "snapshot_data" JSONB, + "title" VARCHAR(255) NOT NULL DEFAULT '', + "content" TEXT NOT NULL DEFAULT '', + "colour" INT4 NOT NULL DEFAULT 0, "image_url" TEXT, "thumbnail_url" TEXT, "button_style" INT2 DEFAULT 1, - "button_label" VARCHAR(80) NOT NULL, + "button_label" VARCHAR(80) NOT NULL DEFAULT '', "emoji_name" VARCHAR(32), "welcome_message" JSONB, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -84,6 +94,7 @@ CREATE TABLE IF NOT EXISTS gallery_listings ( ); CREATE INDEX IF NOT EXISTS gallery_listings_status_idx ON gallery_listings("status"); CREATE INDEX IF NOT EXISTS gallery_listings_category_idx ON gallery_listings("category"); +CREATE INDEX IF NOT EXISTS gallery_listings_listing_type_idx ON gallery_listings("listing_type"); CREATE INDEX IF NOT EXISTS gallery_listings_submitter_user_id_idx ON gallery_listings("submitter_user_id"); CREATE INDEX IF NOT EXISTS gallery_listings_source_guild_id_idx ON gallery_listings("source_guild_id"); CREATE INDEX IF NOT EXISTS gallery_listings_featured_idx ON gallery_listings("featured") WHERE "featured" = true; @@ -97,6 +108,7 @@ func (l *GalleryListing) fieldPtrs() []interface{} { &l.Id, &l.SubmitterUserId, &l.SourceGuildId, + &l.ListingType, &l.Name, &l.Description, &l.Category, @@ -106,6 +118,7 @@ func (l *GalleryListing) fieldPtrs() []interface{} { &l.ReviewedAt, &l.ImportCount, &l.Featured, + &l.SnapshotData, &l.Title, &l.Content, &l.Colour, @@ -124,6 +137,7 @@ const galleryListingSelectColumns = ` "id", "submitter_user_id", "source_guild_id", + "listing_type", "name", "description", "category", @@ -133,6 +147,7 @@ const galleryListingSelectColumns = ` "reviewed_at", "import_count", "featured", + "snapshot_data", "title", "content", "colour", @@ -151,10 +166,12 @@ func (t *GalleryListingsTable) Create(ctx context.Context, listing GalleryListin INSERT INTO gallery_listings ( "submitter_user_id", "source_guild_id", + "listing_type", "name", "description", "category", "status", + "snapshot_data", "title", "content", "colour", @@ -165,17 +182,24 @@ INSERT INTO gallery_listings ( "emoji_name", "welcome_message" ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING "id";` + listingType := listing.ListingType + if listingType == "" { + listingType = GalleryListingTypePanel + } + var id int err := t.QueryRow(ctx, query, listing.SubmitterUserId, listing.SourceGuildId, + listingType, listing.Name, listing.Description, listing.Category, listing.Status, + listing.SnapshotData, listing.Title, listing.Content, listing.Colour, @@ -207,12 +231,18 @@ func (t *GalleryListingsTable) GetById(ctx context.Context, id int) (listing Gal // GetApproved returns paginated approved listings with optional filters. // Sort must be "popular" (import_count DESC) or "recent" (created_at DESC). // Search matches name or description using case-insensitive ILIKE. -func (t *GalleryListingsTable) GetApproved(ctx context.Context, category string, tag string, search string, sort string, page int, pageSize int) ([]GalleryListing, int, error) { +// listingType filters by type when non-empty ("panel", "tag", "form"). +func (t *GalleryListingsTable) GetApproved(ctx context.Context, category string, tag string, search string, sort string, listingType string, page int, pageSize int) ([]GalleryListing, int, error) { var conditions []string var args []interface{} conditions = append(conditions, `"status" = 'approved'`) + if listingType != "" { + args = append(args, listingType) + conditions = append(conditions, fmt.Sprintf(`"listing_type" = $%d`, len(args))) + } + if category != "" { args = append(args, category) conditions = append(conditions, fmt.Sprintf(`"category" = $%d`, len(args))) @@ -419,7 +449,7 @@ func (t *GalleryListingsTable) UpdateMetadata(ctx context.Context, id int, name return err } -// Resubmit replaces a listing's panel snapshot and metadata, resetting it for review. +// Resubmit replaces a listing's snapshot and metadata, resetting it for review. func (t *GalleryListingsTable) Resubmit(ctx context.Context, listing GalleryListing) error { query := ` UPDATE gallery_listings SET @@ -430,15 +460,16 @@ UPDATE gallery_listings SET "review_note" = NULL, "reviewed_by" = NULL, "reviewed_at" = NULL, - "title" = $6, - "content" = $7, - "colour" = $8, - "image_url" = $9, - "thumbnail_url" = $10, - "button_style" = $11, - "button_label" = $12, - "emoji_name" = $13, - "welcome_message" = $14, + "snapshot_data" = $6, + "title" = $7, + "content" = $8, + "colour" = $9, + "image_url" = $10, + "thumbnail_url" = $11, + "button_style" = $12, + "button_label" = $13, + "emoji_name" = $14, + "welcome_message" = $15, "updated_at" = NOW() WHERE "id" = $1;` @@ -448,6 +479,7 @@ WHERE "id" = $1;` listing.Description, listing.Category, listing.Status, + listing.SnapshotData, listing.Title, listing.Content, listing.Colour, diff --git a/gallery_snapshots.go b/gallery_snapshots.go new file mode 100644 index 0000000..4c0e6f0 --- /dev/null +++ b/gallery_snapshots.go @@ -0,0 +1,43 @@ +package database + +type GalleryPanelSnapshot struct { + Title string `json:"title"` + Content string `json:"content"` + Colour int32 `json:"colour"` + ImageUrl *string `json:"image_url,omitempty"` + ThumbnailUrl *string `json:"thumbnail_url,omitempty"` + ButtonStyle *int16 `json:"button_style"` + ButtonLabel string `json:"button_label"` + EmojiName *string `json:"emoji_name,omitempty"` + WelcomeMessage []byte `json:"welcome_message,omitempty"` +} + +type GalleryTagSnapshot struct { + Content *string `json:"content,omitempty"` + Embed *CustomEmbedWithFields `json:"embed,omitempty"` +} + +type GalleryFormSnapshot struct { + Title string `json:"title"` + Inputs []GalleryFormInputSnapshot `json:"inputs"` +} + +type GalleryFormInputSnapshot struct { + Type int `json:"type"` + Position int `json:"position"` + Style uint8 `json:"style"` + Label string `json:"label"` + Description *string `json:"description,omitempty"` + Placeholder *string `json:"placeholder,omitempty"` + Required bool `json:"required"` + MinLength *uint16 `json:"min_length,omitempty"` + MaxLength *uint16 `json:"max_length,omitempty"` + Options []GalleryFormInputOptionSnapshot `json:"options,omitempty"` +} + +type GalleryFormInputOptionSnapshot struct { + Position int `json:"position"` + Label string `json:"label"` + Description *string `json:"description,omitempty"` + Value string `json:"value"` +} From 6788adfcf717cd071db5f269b39e5924d02e7945 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 26 Apr 2026 18:18:05 +0100 Subject: [PATCH 12/49] centralise bot staff Signed-off-by: Ben --- auditlog.go | 5 +++-- botstaff.go | 63 ++++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/auditlog.go b/auditlog.go index 52b0341..609f85b 100644 --- a/auditlog.go +++ b/auditlog.go @@ -86,8 +86,9 @@ const ( AuditActionWhitelabelStatusSet AuditActionType = 213 AuditActionWhitelabelStatusDelete AuditActionType = 214 - AuditActionBotStaffAdd AuditActionType = 300 - AuditActionBotStaffRemove AuditActionType = 301 + AuditActionBotStaffAdd AuditActionType = 300 + AuditActionBotStaffRemove AuditActionType = 301 + AuditActionBotStaffTierUpdate AuditActionType = 302 AuditActionGlobalBlacklistAdd AuditActionType = 310 AuditActionGlobalBlacklistRemove AuditActionType = 311 diff --git a/botstaff.go b/botstaff.go index e3e3a2b..ce24eb3 100644 --- a/botstaff.go +++ b/botstaff.go @@ -2,9 +2,23 @@ package database import ( "context" + + "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" ) +type BotStaffTier string + +const ( + BotStaffTierAdmin BotStaffTier = "admin" + BotStaffTierHelper BotStaffTier = "helper" +) + +type BotStaffEntry struct { + UserId uint64 `json:"id,string"` + Tier BotStaffTier `json:"tier"` +} + type BotStaff struct { *pgxpool.Pool } @@ -19,6 +33,7 @@ func (s BotStaff) Schema() string { return ` CREATE TABLE IF NOT EXISTS bot_staff( "user_id" int8 NOT NULL UNIQUE, + "tier" TEXT NOT NULL DEFAULT 'helper', PRIMARY KEY("user_id") );` } @@ -28,7 +43,7 @@ func (s *BotStaff) IsStaff(ctx context.Context, userId uint64) (isStaff bool, er SELECT EXISTS ( SELECT 1 FROM bot_staff - where "user_id" = $1 + WHERE "user_id" = $1 ); ` @@ -36,8 +51,20 @@ SELECT EXISTS ( return } -func (s *BotStaff) GetAll(ctx context.Context) ([]uint64, error) { - query := `SELECT "user_id" FROM bot_staff;` +func (s *BotStaff) GetTier(ctx context.Context, userId uint64) (BotStaffTier, error) { + query := `SELECT "tier" FROM bot_staff WHERE "user_id" = $1` + + var tier BotStaffTier + err := s.QueryRow(ctx, query, userId).Scan(&tier) + if err == pgx.ErrNoRows { + return "", nil + } + + return tier, err +} + +func (s *BotStaff) GetAll(ctx context.Context) ([]BotStaffEntry, error) { + query := `SELECT "user_id", "tier" FROM bot_staff ORDER BY "tier", "user_id";` rows, err := s.Query(ctx, query) if err != nil { @@ -46,27 +73,37 @@ func (s *BotStaff) GetAll(ctx context.Context) ([]uint64, error) { defer rows.Close() - var userIds []uint64 + var entries []BotStaffEntry for rows.Next() { - var userId uint64 - if err = rows.Scan(&userId); err != nil { + var entry BotStaffEntry + if err = rows.Scan(&entry.UserId, &entry.Tier); err != nil { return nil, err } - userIds = append(userIds, userId) + entries = append(entries, entry) } - return userIds, nil + return entries, nil } -func (s *BotStaff) Add(ctx context.Context, userId uint64) (err error) { +func (s *BotStaff) Add(ctx context.Context, userId uint64, tier BotStaffTier) (err error) { query := ` -INSERT INTO bot_staff("user_id") -VALUES($1) -ON CONFLICT("user_id") DO NOTHING; +INSERT INTO bot_staff("user_id", "tier") +VALUES($1, $2) +ON CONFLICT("user_id") DO UPDATE SET "tier" = $2; ` - _, err = s.Exec(ctx, query, userId) + _, err = s.Exec(ctx, query, userId, tier) + return +} + +func (s *BotStaff) UpdateTier(ctx context.Context, userId uint64, tier BotStaffTier) (err error) { + query := ` +UPDATE bot_staff +SET "tier" = $2 +WHERE "user_id" = $1;` + + _, err = s.Exec(ctx, query, userId, tier) return } From 0ff8524f386ca5ec8502480f5927e4ca4e19212a Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 27 Apr 2026 20:47:47 +0100 Subject: [PATCH 13/49] stats Signed-off-by: Ben --- analyticstypes.go | 29 +++++++++++++ closereason.go | 17 ++++++++ serviceratings.go | 71 ++++++++++++++++++++++++++++++++ ticketclaims.go | 14 +++++++ ticketlabelassignments.go | 28 +++++++++++++ tickets.go | 85 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+) diff --git a/analyticstypes.go b/analyticstypes.go index 044ea37..df5375b 100644 --- a/analyticstypes.go +++ b/analyticstypes.go @@ -12,3 +12,32 @@ type CountOnDate struct { Date time.Time `json:"date"` Count uint64 `json:"count"` } + +type PanelTicketCount struct { + PanelId *int `json:"panel_id"` + PanelTitle string `json:"panel_title"` + Count int `json:"count"` +} + +type LabelTicketCount struct { + LabelId int `json:"label_id"` + Name string `json:"name"` + Colour int32 `json:"colour"` + Count int `json:"count"` +} + +type ThreadChannelSplit struct { + ThreadCount int `json:"thread_count"` + ChannelCount int `json:"channel_count"` +} + +type FeedbackResponseRate struct { + ClosedTickets int `json:"closed_tickets"` + RatedTickets int `json:"rated_tickets"` + Rate float64 `json:"rate"` +} + +type AutoCloseStats struct { + AutoClosed int `json:"auto_closed"` + ManualClosed int `json:"manual_closed"` +} diff --git a/closereason.go b/closereason.go index 1780a0d..2cdf1a6 100644 --- a/closereason.go +++ b/closereason.go @@ -151,6 +151,23 @@ LIMIT $3;` return reasons, nil } +func (c *CloseMetadataTable) GetAutoCloseVsManualClose(ctx context.Context, guildId uint64, nDays int) (AutoCloseStats, error) { + query := ` +SELECT + COUNT(*) FILTER (WHERE cr.closed_by IS NULL), + COUNT(*) FILTER (WHERE cr.closed_by IS NOT NULL) +FROM close_reason cr +INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id +WHERE cr.guild_id = $1 AND t.close_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day';` + + var stats AutoCloseStats + if err := c.QueryRow(ctx, query, guildId, nDays).Scan(&stats.AutoClosed, &stats.ManualClosed); err != nil { + return AutoCloseStats{}, err + } + + return stats, nil +} + func (c *CloseMetadataTable) GetTopCloseReasonsContaining(ctx context.Context, guildId uint64, panelId *int, contains string, limit int) ([]string, error) { query := ` SELECT cr.close_reason diff --git a/serviceratings.go b/serviceratings.go index 1043ab9..1e0dd0e 100644 --- a/serviceratings.go +++ b/serviceratings.go @@ -157,3 +157,74 @@ ON CONFLICT("guild_id", "ticket_id") DO UPDATE SET "rating" = $3;` _, err = r.Exec(ctx, query, guildId, ticketId, rating) return } + +func (r *ServiceRatings) GetDistributionClaimedBy(ctx context.Context, guildId, userId uint64) ([5]int, error) { + query := ` +SELECT sr.rating, COUNT(*) +FROM service_ratings sr +INNER JOIN ticket_claims tc ON sr.guild_id = tc.guild_id AND sr.ticket_id = tc.ticket_id +WHERE sr.guild_id = $1 AND tc.user_id = $2 +GROUP BY sr.rating ORDER BY sr.rating;` + + rows, err := r.Query(ctx, query, guildId, userId) + if err != nil { + return [5]int{}, err + } + defer rows.Close() + + var dist [5]int + for rows.Next() { + var rating, count int + if err := rows.Scan(&rating, &count); err != nil { + return [5]int{}, err + } + if rating >= 1 && rating <= 5 { + dist[rating-1] = count + } + } + + return dist, nil +} + +func (r *ServiceRatings) GetDistribution(ctx context.Context, guildId uint64) ([5]int, error) { + query := `SELECT rating, COUNT(*) FROM service_ratings WHERE guild_id = $1 GROUP BY rating ORDER BY rating;` + + rows, err := r.Query(ctx, query, guildId) + if err != nil { + return [5]int{}, err + } + defer rows.Close() + + var dist [5]int + for rows.Next() { + var rating, count int + if err := rows.Scan(&rating, &count); err != nil { + return [5]int{}, err + } + if rating >= 1 && rating <= 5 { + dist[rating-1] = count + } + } + + return dist, nil +} + +func (r *ServiceRatings) GetResponseRate(ctx context.Context, guildId uint64, nDays int) (FeedbackResponseRate, error) { + query := ` +SELECT COUNT(t.id) AS closed, COUNT(sr.rating) AS rated +FROM tickets t +LEFT JOIN service_ratings sr ON t.guild_id = sr.guild_id AND t.id = sr.ticket_id +WHERE t.guild_id = $1 AND t.open = false + AND t.close_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day';` + + var result FeedbackResponseRate + if err := r.QueryRow(ctx, query, guildId, nDays).Scan(&result.ClosedTickets, &result.RatedTickets); err != nil { + return FeedbackResponseRate{}, err + } + + if result.ClosedTickets > 0 { + result.Rate = float64(result.RatedTickets) / float64(result.ClosedTickets) + } + + return result, nil +} diff --git a/ticketclaims.go b/ticketclaims.go index 66bd4ec..cc9f8a5 100644 --- a/ticketclaims.go +++ b/ticketclaims.go @@ -75,3 +75,17 @@ func (c *TicketClaims) GetClaimedCount(ctx context.Context, guildId, userId uint return } + +func (c *TicketClaims) GetOpenClaimedCount(ctx context.Context, guildId, userId uint64) (count int, e error) { + query := ` +SELECT COUNT(*) +FROM ticket_claims +INNER JOIN tickets ON ticket_claims.guild_id = tickets.guild_id AND ticket_claims.ticket_id = tickets.id +WHERE ticket_claims.guild_id = $1 AND ticket_claims.user_id = $2 AND tickets.open = true;` + + if err := c.QueryRow(ctx, query, guildId, userId).Scan(&count); err != nil && err != pgx.ErrNoRows { + e = err + } + + return +} diff --git a/ticketlabelassignments.go b/ticketlabelassignments.go index c56338e..895f50c 100644 --- a/ticketlabelassignments.go +++ b/ticketlabelassignments.go @@ -140,3 +140,31 @@ func (t *TicketLabelAssignmentsTable) Replace(ctx context.Context, guildId uint6 return tx.Commit(ctx) } + +func (t *TicketLabelAssignmentsTable) GetTicketCountByLabel(ctx context.Context, guildId uint64, nDays int) ([]LabelTicketCount, error) { + query := ` +SELECT tla.label_id, tl.name, tl.colour, COUNT(*) +FROM ticket_label_assignments tla +INNER JOIN ticket_labels tl ON tla.guild_id = tl.guild_id AND tla.label_id = tl.label_id +INNER JOIN tickets t ON tla.guild_id = t.guild_id AND tla.ticket_id = t.id +WHERE tla.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day' +GROUP BY tla.label_id, tl.name, tl.colour +ORDER BY COUNT(*) DESC;` + + rows, err := t.Query(ctx, query, guildId, nDays) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []LabelTicketCount + for rows.Next() { + var r LabelTicketCount + if err := rows.Scan(&r.LabelId, &r.Name, &r.Colour, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} diff --git a/tickets.go b/tickets.go index d549cff..5d3499d 100644 --- a/tickets.go +++ b/tickets.go @@ -1413,3 +1413,88 @@ func secondsToDuration(secs *float64) *time.Duration { d := time.Duration(*secs * float64(time.Second)) return &d } + +func (t *TicketTable) GetTicketCountByPanel(ctx context.Context, guildId uint64, nDays int) ([]PanelTicketCount, error) { + query := ` +SELECT t.panel_id, COALESCE(p.title, 'No Panel'), COUNT(*) +FROM tickets t +LEFT JOIN panels p ON t.panel_id = p.panel_id +WHERE t.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day' +GROUP BY t.panel_id, p.title +ORDER BY COUNT(*) DESC;` + + rows, err := t.Query(ctx, query, guildId, nDays) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []PanelTicketCount + for rows.Next() { + var r PanelTicketCount + if err := rows.Scan(&r.PanelId, &r.PanelTitle, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + +func (t *TicketTable) GetThreadChannelSplit(ctx context.Context, guildId uint64, nDays int) (ThreadChannelSplit, error) { + query := ` +SELECT + COUNT(*) FILTER (WHERE is_thread = true), + COUNT(*) FILTER (WHERE is_thread = false) +FROM tickets +WHERE guild_id = $1 AND open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day';` + + var split ThreadChannelSplit + if err := t.QueryRow(ctx, query, guildId, nDays).Scan(&split.ThreadCount, &split.ChannelCount); err != nil { + return ThreadChannelSplit{}, err + } + + return split, nil +} + +func (t *TicketTable) GetBacklogTrend(ctx context.Context, guildId uint64, nDays int) ([]CountOnDate, error) { + query := ` +WITH opens AS ( + SELECT date_trunc('day', open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*) AS cnt + FROM tickets WHERE guild_id = $1 GROUP BY 1 +), closes AS ( + SELECT date_trunc('day', close_time AT TIME ZONE 'UTC')::date AS date, COUNT(*) AS cnt + FROM tickets WHERE guild_id = $1 AND close_time IS NOT NULL GROUP BY 1 +), series AS ( + SELECT d::date AS date FROM generate_series(CURRENT_DATE - ($2 - 1) * INTERVAL '1 day', CURRENT_DATE, '1 day') d +), base_open AS ( + SELECT COUNT(*) AS cnt FROM tickets WHERE guild_id = $1 + AND open_time < CURRENT_DATE - ($2 - 1) * INTERVAL '1 day' + AND (close_time IS NULL OR close_time >= CURRENT_DATE - ($2 - 1) * INTERVAL '1 day') +) +SELECT s.date, + (SELECT cnt FROM base_open) + + SUM(COALESCE(o.cnt, 0)) OVER (ORDER BY s.date) + - SUM(COALESCE(c.cnt, 0)) OVER (ORDER BY s.date) AS count +FROM series s +LEFT JOIN opens o ON o.date = s.date +LEFT JOIN closes c ON c.date = s.date +ORDER BY s.date;` + + rows, err := t.Query(ctx, query, guildId, nDays) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make([]CountOnDate, 0, nDays) + for rows.Next() { + var c CountOnDate + if err := rows.Scan(&c.Date, &c.Count); err != nil { + return nil, err + } + counts = append(counts, c) + } + + return counts, nil +} From eb571b124e64f35debfbe6421bd7f37d1433f3a0 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 10 May 2026 15:43:04 +0200 Subject: [PATCH 14/49] Panel: add PanelAutoClose and new panel settings Move several guild-level settings into panel scope and add per-panel auto-close. Removed legacy global tables (auto_close, close_confirmation, feedback_enabled, users_can_close) and their code. Added panelautoclose.go implementing PanelAutoCloseTable and registered it in database.go. Expanded panels schema and Panel struct with fields (store_transcripts, thread_archive_duration, overflow_enabled, overflow_category_id, users_can_close, close_confirmation, feedback_enabled, support_can_view, support_can_type), updated all related SQL queries, inserts and updates, and added SetOverflow. Simplified ClaimSettings by removing deprecated fields and adjusted its schema/queries. Removed global overflow/transcript settings from settings.go (migrated to panel-level). Updated guild purge and multipanel targets to reflect removed/added columns. --- autoclose.go | 86 ------------------------- claimsettings.go | 37 ++--------- closeconfirmation.go | 43 ------------- database.go | 15 +---- feedbackenabled.go | 34 ---------- guildpurge.go | 4 -- multipaneltargets.go | 9 +++ panelautoclose.go | 64 +++++++++++++++++++ panels.go | 148 ++++++++++++++++++++++++++++++++++++++++--- settings.go | 55 ++-------------- usercanclose.go | 43 ------------- 11 files changed, 229 insertions(+), 309 deletions(-) delete mode 100644 autoclose.go delete mode 100644 closeconfirmation.go delete mode 100644 feedbackenabled.go create mode 100644 panelautoclose.go delete mode 100644 usercanclose.go diff --git a/autoclose.go b/autoclose.go deleted file mode 100644 index c247fd7..0000000 --- a/autoclose.go +++ /dev/null @@ -1,86 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" - "time" -) - -type AutoCloseTable struct { - *pgxpool.Pool -} - -type AutoCloseSettings struct { - Enabled bool `json:"enabled"` - SinceOpenWithNoResponse *time.Duration `json:"since_open_with_no_response"` - SinceLastMessage *time.Duration `json:"since_last_message"` - OnUserLeave *bool `json:"on_user_leave"` -} - -func newAutoCloseTable(db *pgxpool.Pool) *AutoCloseTable { - return &AutoCloseTable{ - db, - } -} - -func (a AutoCloseTable) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS auto_close( - "guild_id" int8 NOT NULL, - "enabled" bool NOT NULL, - "since_open_with_no_response" interval, - "since_last_message" interval, - "on_user_leave" bool, - PRIMARY KEY("guild_id") -); -` -} - -func (a *AutoCloseTable) Get(ctx context.Context, guildId uint64) (settings AutoCloseSettings, e error) { - query := `SELECT "enabled", "since_open_with_no_response", "since_last_message", "on_user_leave" FROM auto_close WHERE "guild_id" = $1;` - if err := a.QueryRow(ctx, query, guildId).Scan(&settings.Enabled, &settings.SinceOpenWithNoResponse, &settings.SinceLastMessage, &settings.OnUserLeave); err != nil && err != pgx.ErrNoRows { // defaults to nil if no rows - e = err - } - - return -} - -func (a *AutoCloseTable) Set(ctx context.Context, guildId uint64, settings AutoCloseSettings) (err error) { - query := ` -INSERT INTO - auto_close("guild_id", "enabled", "since_open_with_no_response", "since_last_message", "on_user_leave") -VALUES - ($1, $2, $3, $4, $5) -ON CONFLICT("guild_id") DO - UPDATE SET - "enabled" = $2, - "since_open_with_no_response" = $3, - "since_last_message" = $4, - "on_user_leave" = $5 -;` - - _, err = a.Exec(ctx, query, guildId, settings.Enabled, settings.SinceOpenWithNoResponse, settings.SinceLastMessage, settings.OnUserLeave) - return -} - -func (a *AutoCloseTable) Reset(ctx context.Context, guildId uint64) (err error) { - query := ` -UPDATE auto_close -SET since_open_with_no_response = NULL, since_last_message = NULL -WHERE "guild_id" = $1; -` - - _, err = a.Exec(ctx, query, guildId) - return -} - -func (a *AutoCloseTable) Delete(ctx context.Context, guildId uint64) (err error) { - query := ` -DELETE FROM auto_close -WHERE "guild_id" = $1; -` - - _, err = a.Exec(ctx, query, guildId) - return -} diff --git a/claimsettings.go b/claimsettings.go index 3df92fe..a3d3daa 100644 --- a/claimsettings.go +++ b/claimsettings.go @@ -29,14 +29,10 @@ const ( ) type ClaimSettings struct { - SupportCanView bool `json:"support_can_view"` - SupportCanType bool `json:"support_can_type"` - SwitchPanelClaimBehavior SwitchPanelClaimBehavior `json:"switch_panel_claim_behavior"` + SwitchPanelClaimBehavior SwitchPanelClaimBehavior `json:"switch_panel_claim_behavior"` } var defaultClaimSettings = ClaimSettings{ - SupportCanView: true, - SupportCanType: false, SwitchPanelClaimBehavior: SwitchPanelAutoUnclaim, } @@ -45,17 +41,13 @@ type ClaimSettingsTable struct { } func newClaimSettingsTable(db *pgxpool.Pool) *ClaimSettingsTable { - return &ClaimSettingsTable{ - db, - } + return &ClaimSettingsTable{db} } func (c ClaimSettingsTable) Schema() string { return ` CREATE TABLE IF NOT EXISTS claim_settings( "guild_id" int8 NOT NULL, - "support_can_view" bool NOT NULL, - "support_can_type" bool NOT NULL, "switch_panel_claim_behavior" int2 NOT NULL DEFAULT 0, PRIMARY KEY("guild_id") ); @@ -63,37 +55,22 @@ CREATE TABLE IF NOT EXISTS claim_settings( } func (c *ClaimSettingsTable) Get(ctx context.Context, guildId uint64) (settings ClaimSettings, e error) { - query := `SELECT "support_can_view", "support_can_type", "switch_panel_claim_behavior" FROM claim_settings WHERE "guild_id" = $1;` - if err := c.QueryRow(ctx, query, guildId).Scan(&settings.SupportCanView, &settings.SupportCanType, &settings.SwitchPanelClaimBehavior); err != nil { + query := `SELECT "switch_panel_claim_behavior" FROM claim_settings WHERE "guild_id" = $1;` + if err := c.QueryRow(ctx, query, guildId).Scan(&settings.SwitchPanelClaimBehavior); err != nil { if err == pgx.ErrNoRows { settings = defaultClaimSettings } else { e = err } } - return } func (c *ClaimSettingsTable) Set(ctx context.Context, guildId uint64, settings ClaimSettings) (err error) { query := ` -INSERT INTO claim_settings("guild_id", "support_can_view", "support_can_type", "switch_panel_claim_behavior") VALUES($1, $2, $3, $4) - ON CONFLICT("guild_id") DO UPDATE SET - "support_can_view" = $2, - "support_can_type" = $3, - "switch_panel_claim_behavior" = $4;` - - _, err = c.Exec(ctx, query, guildId, settings.SupportCanView, settings.SupportCanType, settings.SwitchPanelClaimBehavior) - return -} - -func (c *ClaimSettingsTable) SetSwitchPanelClaimBehavior(ctx context.Context, guildId uint64, behavior SwitchPanelClaimBehavior) (err error) { - query := ` -INSERT INTO claim_settings("guild_id", "support_can_view", "support_can_type", "switch_panel_claim_behavior") -VALUES($1, $2, $3, $4) -ON CONFLICT("guild_id") DO UPDATE SET "switch_panel_claim_behavior" = $4;` +INSERT INTO claim_settings("guild_id", "switch_panel_claim_behavior") VALUES($1, $2) +ON CONFLICT("guild_id") DO UPDATE SET "switch_panel_claim_behavior" = $2;` - defaults := defaultClaimSettings - _, err = c.Exec(ctx, query, guildId, defaults.SupportCanView, defaults.SupportCanType, behavior) + _, err = c.Exec(ctx, query, guildId, settings.SwitchPanelClaimBehavior) return } diff --git a/closeconfirmation.go b/closeconfirmation.go deleted file mode 100644 index 5c7a2e6..0000000 --- a/closeconfirmation.go +++ /dev/null @@ -1,43 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type CloseConfirmation struct { - *pgxpool.Pool -} - -func newCloseConfirmation(db *pgxpool.Pool) *CloseConfirmation { - return &CloseConfirmation{ - db, - } -} - -func (c CloseConfirmation) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS close_confirmation( - "guild_id" int8 NOT NULL UNIQUE, - "confirm" bool NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (c *CloseConfirmation) Get(ctx context.Context, guildId uint64) (confirm bool, e error) { - if err := c.QueryRow(ctx, `SELECT "confirm" from close_confirmation WHERE "guild_id" = $1;`, guildId).Scan(&confirm); err != nil { - if err == pgx.ErrNoRows { - confirm = true - } else { - e = err - } - } - - return -} - -func (c *CloseConfirmation) Set(ctx context.Context, guildId uint64, confirm bool) (err error) { - _, err = c.Exec(ctx, `INSERT INTO close_confirmation("guild_id", "confirm") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "confirm" = $2;`, guildId, confirm) - return -} diff --git a/database.go b/database.go index 94b1e46..ed2b23c 100644 --- a/database.go +++ b/database.go @@ -16,14 +16,12 @@ type Database struct { ArchiveChannel *ArchiveChannel AuditLog *AuditLogTable ArchiveMessages *ArchiveMessages - AutoClose *AutoCloseTable AutoCloseExclude *AutoCloseExclude Blacklist *Blacklist BotStaff *BotStaff CategoryUpdateQueue *CategoryUpdateQueue ChannelCategory *ChannelCategory ClaimSettings *ClaimSettingsTable - CloseConfirmation *CloseConfirmation CloseReason *CloseMetadataTable CloseRequest *CloseRequestTable CustomIntegrations *CustomIntegrationTable @@ -43,7 +41,6 @@ type Database struct { Entitlements *Entitlements ExitSurveyResponses *ExitSurveyResponses Experiment *ExperimentTable - FeedbackEnabled *FeedbackEnabled FirstResponseTime *FirstResponseTime FormInput *FormInputTable FormInputOption *FormInputOptionTable @@ -74,6 +71,7 @@ type Database struct { PanelSupportHoursSettings *PanelSupportHoursSettingsTable PanelTeams *PanelTeamsTable PanelTicketPermissions *PanelTicketPermissionsTable + PanelAutoClose *PanelAutoCloseTable PanelUserMention *PanelUserMention PanelHereMention *PanelHereMention Participants *ParticipantTable @@ -103,7 +101,6 @@ type Database struct { TicketPermissions *TicketPermissionsTable Tickets *TicketTable UsedKeys *UsedKeys - UsersCanClose *UsersCanClose UserGuilds *UserGuildsTable VoteCredits *VoteCredits Votes *Votes @@ -125,14 +122,12 @@ func NewDatabase(pool *pgxpool.Pool) *Database { ArchiveChannel: newArchiveChannel(pool), AuditLog: newAuditLogTable(pool), ArchiveMessages: newArchiveMessages(pool), - AutoClose: newAutoCloseTable(pool), AutoCloseExclude: newAutoCloseExclude(pool), Blacklist: newBlacklist(pool), BotStaff: newBotStaff(pool), CategoryUpdateQueue: newCategoryUpdateQueueTable(pool), ChannelCategory: newChannelCategory(pool), ClaimSettings: newClaimSettingsTable(pool), - CloseConfirmation: newCloseConfirmation(pool), CloseReason: newCloseReasonTable(pool), CloseRequest: newCloseRequestTable(pool), CustomIntegrations: newCustomIntegrationTable(pool), @@ -152,7 +147,6 @@ func NewDatabase(pool *pgxpool.Pool) *Database { Entitlements: newEntitlementsTable(pool), ExitSurveyResponses: newExitSurveyResponses(pool), Experiment: newExperimentTable(pool), - FeedbackEnabled: newFeedbackEnabled(pool), FirstResponseTime: newFirstResponseTime(pool), FormInput: newFormInputTable(pool), Forms: newFormsTable(pool), @@ -183,6 +177,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { PanelSupportHoursSettings: newPanelSupportHoursSettingsTable(pool), PanelTeams: newPanelTeamsTable(pool), PanelTicketPermissions: newPanelTicketPermissionsTable(pool), + PanelAutoClose: newPanelAutoCloseTable(pool), PanelUserMention: newPanelUserMention(pool), PanelHereMention: newPanelHereMention(pool), Participants: newParticipantTable(pool), @@ -212,7 +207,6 @@ func NewDatabase(pool *pgxpool.Pool) *Database { TicketPermissions: newTicketPermissionsTable(pool), Tickets: newTicketTable(pool), UsedKeys: newUsedKeys(pool), - UsersCanClose: newUsersCanClose(pool), UserGuilds: newUserGuildsTable(pool), VoteCredits: newVoteCreditsTable(pool), Votes: newVotes(pool), @@ -258,12 +252,10 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { mustCreate(ctx, pool, d.ActiveLanguage, d.ArchiveChannel, - d.AutoClose, d.Blacklist, d.BotStaff, d.ChannelCategory, d.ClaimSettings, - d.CloseConfirmation, d.CustomIntegrations, d.CustomIntegrationGuilds, d.CustomIntegrationGuildCounts, @@ -281,7 +273,6 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.Skus, // must be created before discord_store_skus, subscription_skus, multi_server_skus, polar_products d.DiscordStoreSkus, // depends on skus d.SubscriptionSkus, // depends on skus - d.FeedbackEnabled, d.Forms, d.FormInput, // depends on forms d.FormInputOption, // depends on form inputs @@ -304,6 +295,7 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.PanelRoleMentions, d.PanelSupportHours, // must be created after panels table d.PanelSupportHoursSettings, // must be created after panels table + d.PanelAutoClose, // must be created after panels table d.PanelUserMention, d.PanelHereMention, d.PatreonEntitlements, @@ -348,7 +340,6 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.TicketMembers, d.TicketClaims, d.UsedKeys, - d.UsersCanClose, d.UserGuilds, d.VoteCredits, d.Votes, diff --git a/feedbackenabled.go b/feedbackenabled.go deleted file mode 100644 index 204896d..0000000 --- a/feedbackenabled.go +++ /dev/null @@ -1,34 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type FeedbackEnabled struct { - *pgxpool.Pool -} - -func newFeedbackEnabled(db *pgxpool.Pool) *FeedbackEnabled { - return &FeedbackEnabled{ - db, - } -} - -func (FeedbackEnabled) Schema() string { - return `CREATE TABLE IF NOT EXISTS feedback_enabled("guild_id" int8 NOT NULL UNIQUE, "feedback_enabled" bool NOT NULL, PRIMARY KEY("guild_id"));` -} - -func (f *FeedbackEnabled) Get(ctx context.Context, guildId uint64) (feedbackEnabled bool, e error) { - if err := f.QueryRow(ctx, `SELECT "feedback_enabled" from feedback_enabled WHERE "guild_id" = $1;`, guildId).Scan(&feedbackEnabled); err != nil && err != pgx.ErrNoRows { - e = err - } - - return -} - -func (f *FeedbackEnabled) Set(ctx context.Context, guildId uint64, feedbackEnabled bool) (err error) { - _, err = f.Exec(ctx, `INSERT INTO feedback_enabled("guild_id", "feedback_enabled") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "feedback_enabled" = $2;`, guildId, feedbackEnabled) - return -} diff --git a/guildpurge.go b/guildpurge.go index 0f551e0..319038f 100644 --- a/guildpurge.go +++ b/guildpurge.go @@ -60,13 +60,10 @@ func (d *Database) PurgeGuildData(ctx context.Context, guildId uint64, logger *z // Other guild-specific tables "active_language", "archive_channel", - "auto_close", "blacklist", "channel_category", "claim_settings", - "close_confirmation", "custom_colours", - "feedback_enabled", "guild_metadata", "legacy_premium_entitlement_guilds", "naming_scheme", @@ -80,7 +77,6 @@ func (d *Database) PurgeGuildData(ctx context.Context, guildId uint64, logger *z "tags", "ticket_limit", "ticket_permissions", - "users_can_close", "user_guilds", "webhooks", "welcome_messages", diff --git a/multipaneltargets.go b/multipaneltargets.go index 89b6dd3..d8cbbde 100644 --- a/multipaneltargets.go +++ b/multipaneltargets.go @@ -79,6 +79,15 @@ SELECT panels.hide_close_with_reason_button, panels.hide_claim_button, panels.show_in_open_command, + panels.store_transcripts, + panels.thread_archive_duration, + panels.overflow_enabled, + panels.overflow_category_id, + panels.users_can_close, + panels.close_confirmation, + panels.feedback_enabled, + panels.support_can_view, + panels.support_can_type, multi_panel_targets.custom_label, multi_panel_targets.description, multi_panel_targets.custom_emoji_name, diff --git a/panelautoclose.go b/panelautoclose.go new file mode 100644 index 0000000..35b05bb --- /dev/null +++ b/panelautoclose.go @@ -0,0 +1,64 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type PanelAutoCloseSettings struct { + Enabled bool `json:"enabled"` + SinceOpenWithNoResponse *time.Duration `json:"since_open_with_no_response"` + SinceLastMessage *time.Duration `json:"since_last_message"` + OnUserLeave *bool `json:"on_user_leave"` +} + +type PanelAutoCloseTable struct { + *pgxpool.Pool +} + +func newPanelAutoCloseTable(db *pgxpool.Pool) *PanelAutoCloseTable { + return &PanelAutoCloseTable{db} +} + +func (PanelAutoCloseTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS panel_auto_close( + "panel_id" int NOT NULL, + "enabled" bool NOT NULL DEFAULT false, + "since_open_with_no_response" interval, + "since_last_message" interval, + "on_user_leave" bool, + FOREIGN KEY("panel_id") REFERENCES panels("panel_id") ON DELETE CASCADE, + PRIMARY KEY("panel_id") +);` +} + +func (t *PanelAutoCloseTable) Get(ctx context.Context, panelId int) (settings PanelAutoCloseSettings, e error) { + query := `SELECT "enabled", "since_open_with_no_response", "since_last_message", "on_user_leave" FROM panel_auto_close WHERE "panel_id" = $1;` + if err := t.QueryRow(ctx, query, panelId).Scan(&settings.Enabled, &settings.SinceOpenWithNoResponse, &settings.SinceLastMessage, &settings.OnUserLeave); err != nil && err != pgx.ErrNoRows { + e = err + } + return +} + +func (t *PanelAutoCloseTable) Set(ctx context.Context, panelId int, settings PanelAutoCloseSettings) (err error) { + query := ` +INSERT INTO panel_auto_close("panel_id", "enabled", "since_open_with_no_response", "since_last_message", "on_user_leave") +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT("panel_id") DO UPDATE SET + "enabled" = $2, + "since_open_with_no_response" = $3, + "since_last_message" = $4, + "on_user_leave" = $5;` + + _, err = t.Exec(ctx, query, panelId, settings.Enabled, settings.SinceOpenWithNoResponse, settings.SinceLastMessage, settings.OnUserLeave) + return +} + +func (t *PanelAutoCloseTable) Delete(ctx context.Context, panelId int) (err error) { + _, err = t.Exec(ctx, `DELETE FROM panel_auto_close WHERE "panel_id" = $1;`, panelId) + return +} diff --git a/panels.go b/panels.go index 1581989..0a12ed3 100644 --- a/panels.go +++ b/panels.go @@ -42,6 +42,15 @@ type Panel struct { HideCloseWithReasonButton bool `json:"hide_close_with_reason_button"` HideClaimButton bool `json:"hide_claim_button"` ShowInOpenCommand bool `json:"show_in_open_command"` + StoreTranscripts bool `json:"store_transcripts"` + ThreadArchiveDuration int `json:"thread_archive_duration"` + OverflowEnabled bool `json:"overflow_enabled"` + OverflowCategoryId *uint64 `json:"overflow_category_id,string"` + UsersCanClose bool `json:"users_can_close"` + CloseConfirmation bool `json:"close_confirmation"` + FeedbackEnabled bool `json:"feedback_enabled"` + SupportCanView bool `json:"support_can_view"` + SupportCanType bool `json:"support_can_type"` } type PanelWithWelcomeMessage struct { @@ -96,6 +105,15 @@ CREATE TABLE IF NOT EXISTS panels( "hide_close_with_reason_button" bool NOT NULL DEFAULT false, "hide_claim_button" bool NOT NULL DEFAULT false, "show_in_open_command" bool NOT NULL DEFAULT false, + "store_transcripts" bool NOT NULL DEFAULT true, + "thread_archive_duration" int NOT NULL DEFAULT 10080, + "overflow_enabled" bool NOT NULL DEFAULT false, + "overflow_category_id" int8 DEFAULT NULL, + "users_can_close" bool NOT NULL DEFAULT true, + "close_confirmation" bool NOT NULL DEFAULT true, + "feedback_enabled" bool NOT NULL DEFAULT false, + "support_can_view" bool NOT NULL DEFAULT true, + "support_can_type" bool NOT NULL DEFAULT false, FOREIGN KEY ("welcome_message") REFERENCES embeds("id") ON DELETE SET NULL, FOREIGN KEY ("form_id") REFERENCES forms("form_id"), FOREIGN KEY ("exit_survey_form_id") REFERENCES forms("form_id"), @@ -143,7 +161,16 @@ SELECT hide_close_button, hide_close_with_reason_button, hide_claim_button, - show_in_open_command + show_in_open_command, + store_transcripts, + thread_archive_duration, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "message_id" = $1; ` @@ -191,7 +218,16 @@ SELECT hide_close_button, hide_close_with_reason_button, hide_claim_button, - show_in_open_command + show_in_open_command, + store_transcripts, + thread_archive_duration, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "panel_id" = $1; ` @@ -240,6 +276,15 @@ SELECT panels.hide_close_with_reason_button, panels.hide_claim_button, panels.show_in_open_command, + panels.store_transcripts, + panels.thread_archive_duration, + panels.overflow_enabled, + panels.overflow_category_id, + panels.users_can_close, + panels.close_confirmation, + panels.feedback_enabled, + panels.support_can_view, + panels.support_can_type, embeds.id, embeds.guild_id, embeds.title, @@ -349,7 +394,16 @@ SELECT hide_close_button, hide_close_with_reason_button, hide_claim_button, - show_in_open_command + show_in_open_command, + store_transcripts, + thread_archive_duration, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "guild_id" = $1 AND "custom_id" = $2; ` @@ -400,7 +454,16 @@ SELECT hide_close_button, hide_close_with_reason_button, hide_claim_button, - show_in_open_command + show_in_open_command, + store_transcripts, + thread_archive_duration, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "guild_id" = $1 AND "form_id" = $2; ` @@ -504,7 +567,16 @@ SELECT hide_close_button, hide_close_with_reason_button, hide_claim_button, - show_in_open_command + show_in_open_command, + store_transcripts, + thread_archive_duration, + overflow_enabled, + overflow_category_id, + users_can_close, + close_confirmation, + feedback_enabled, + support_can_view, + support_can_type FROM panels WHERE "guild_id" = $1 ORDER BY "panel_id" ASC;` @@ -563,6 +635,15 @@ SELECT panels.hide_close_with_reason_button, panels.hide_claim_button, panels.show_in_open_command, + panels.store_transcripts, + panels.thread_archive_duration, + panels.overflow_enabled, + panels.overflow_category_id, + panels.users_can_close, + panels.close_confirmation, + panels.feedback_enabled, + panels.support_can_view, + panels.support_can_type, embeds.id, embeds.guild_id, embeds.title, @@ -689,9 +770,18 @@ INSERT INTO panels( "hide_close_button", "hide_close_with_reason_button", "hide_claim_button", - "show_in_open_command" + "show_in_open_command", + "store_transcripts", + "thread_archive_duration", + "overflow_enabled", + "overflow_category_id", + "users_can_close", + "close_confirmation", + "feedback_enabled", + "support_can_view", + "support_can_type" ) -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32) +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41) ON CONFLICT("message_id") DO NOTHING RETURNING "panel_id";` @@ -728,6 +818,15 @@ RETURNING "panel_id";` panel.HideCloseWithReasonButton, panel.HideClaimButton, panel.ShowInOpenCommand, + panel.StoreTranscripts, + panel.ThreadArchiveDuration, + panel.OverflowEnabled, + panel.OverflowCategoryId, + panel.UsersCanClose, + panel.CloseConfirmation, + panel.FeedbackEnabled, + panel.SupportCanView, + panel.SupportCanType, ).Scan(&panelId) return @@ -781,7 +880,16 @@ UPDATE panels "hide_close_button" = $29, "hide_close_with_reason_button" = $30, "hide_claim_button" = $31, - "show_in_open_command" = $32 + "show_in_open_command" = $32, + "store_transcripts" = $33, + "thread_archive_duration" = $34, + "overflow_enabled" = $35, + "overflow_category_id" = $36, + "users_can_close" = $37, + "close_confirmation" = $38, + "feedback_enabled" = $39, + "support_can_view" = $40, + "support_can_type" = $41 WHERE "panel_id" = $1 ;` @@ -819,11 +927,26 @@ UPDATE panels panel.HideCloseWithReasonButton, panel.HideClaimButton, panel.ShowInOpenCommand, + panel.StoreTranscripts, + panel.ThreadArchiveDuration, + panel.OverflowEnabled, + panel.OverflowCategoryId, + panel.UsersCanClose, + panel.CloseConfirmation, + panel.FeedbackEnabled, + panel.SupportCanView, + panel.SupportCanType, ) return err } +func (p *PanelTable) SetOverflow(ctx context.Context, panelId int, enabled bool, categoryId *uint64) (err error) { + query := `UPDATE panels SET "overflow_enabled" = $2, "overflow_category_id" = $3 WHERE "panel_id" = $1;` + _, err = p.Exec(ctx, query, panelId, enabled, categoryId) + return +} + func (p *PanelTable) UpdateMessageId(ctx context.Context, panelId int, messageId uint64) (err error) { query := ` UPDATE panels @@ -943,5 +1066,14 @@ func (p *Panel) fieldPtrs() []interface{} { &p.HideCloseWithReasonButton, &p.HideClaimButton, &p.ShowInOpenCommand, + &p.StoreTranscripts, + &p.ThreadArchiveDuration, + &p.OverflowEnabled, + &p.OverflowCategoryId, + &p.UsersCanClose, + &p.CloseConfirmation, + &p.FeedbackEnabled, + &p.SupportCanView, + &p.SupportCanType, } } diff --git a/settings.go b/settings.go index c074372..69bfa9a 100644 --- a/settings.go +++ b/settings.go @@ -7,14 +7,10 @@ import ( ) type Settings struct { - ContextMenuPermissionLevel int `json:"context_menu_permission_level,string"` - ContextMenuAddSender bool `json:"context_menu_add_sender"` - ContextMenuPanel *int `json:"context_menu_panel"` - StoreTranscripts bool `json:"store_transcripts"` - ThreadArchiveDuration int `json:"thread_archive_duration"` - OverflowEnabled bool `json:"overflow_enabled"` - OverflowCategoryId *uint64 `json:"overflow_category_id,string"` // If overflow_enabled and nil, use root - AnonymiseDashboardResponses bool `json:"anonymise_dashboard_responses"` + ContextMenuPermissionLevel int `json:"context_menu_permission_level,string"` + ContextMenuAddSender bool `json:"context_menu_add_sender"` + ContextMenuPanel *int `json:"context_menu_panel"` + AnonymiseDashboardResponses bool `json:"anonymise_dashboard_responses"` } func defaultSettings() Settings { @@ -22,10 +18,6 @@ func defaultSettings() Settings { ContextMenuPermissionLevel: 0, ContextMenuAddSender: true, ContextMenuPanel: nil, - StoreTranscripts: true, - ThreadArchiveDuration: 10080, - OverflowEnabled: false, - OverflowCategoryId: nil, AnonymiseDashboardResponses: false, } } @@ -47,10 +39,6 @@ CREATE TABLE IF NOT EXISTS settings( "context_menu_permission_level" int DEFAULT '0', "context_menu_add_sender" bool DEFAULT 't', "context_menu_panel" int DEFAULT NULL, - "store_transcripts" bool DEFAULT 't', - "thread_archive_duration" int DEFAULT '10080', - "overflow_enabled" bool DEFAULT 'f', - "overflow_category_id" int8 DEFAULT NULL, "anonymise_dashboard_responses" bool DEFAULT 'f', FOREIGN KEY("context_menu_panel") REFERENCES panels("panel_id") ON DELETE SET NULL, PRIMARY KEY("guild_id") @@ -64,10 +52,6 @@ SELECT "context_menu_permission_level", "context_menu_add_sender", "context_menu_panel", - "store_transcripts", - "thread_archive_duration", - "overflow_enabled", - "overflow_category_id", "anonymise_dashboard_responses" FROM settings WHERE "guild_id" = $1; @@ -78,10 +62,6 @@ WHERE "guild_id" = $1; &settings.ContextMenuPermissionLevel, &settings.ContextMenuAddSender, &settings.ContextMenuPanel, - &settings.StoreTranscripts, - &settings.ThreadArchiveDuration, - &settings.OverflowEnabled, - &settings.OverflowCategoryId, &settings.AnonymiseDashboardResponses, ) @@ -101,23 +81,15 @@ INSERT INTO settings( "context_menu_permission_level", "context_menu_add_sender", "context_menu_panel", - "store_transcripts", - "thread_archive_duration", - "overflow_enabled", - "overflow_category_id", "anonymise_dashboard_responses" ) -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) +VALUES($1, $2, $3, $4, $5) ON CONFLICT("guild_id") DO UPDATE SET "context_menu_permission_level" = $2, "context_menu_add_sender" = $3, "context_menu_panel" = $4, - "store_transcripts" = $5, - "thread_archive_duration" = $6, - "overflow_enabled" = $7, - "overflow_category_id" = $8, - "anonymise_dashboard_responses" = $9 + "anonymise_dashboard_responses" = $5 ; ` @@ -126,10 +98,6 @@ DO UPDATE SET settings.ContextMenuPermissionLevel, settings.ContextMenuAddSender, settings.ContextMenuPanel, - settings.StoreTranscripts, - settings.ThreadArchiveDuration, - settings.OverflowEnabled, - settings.OverflowCategoryId, settings.AnonymiseDashboardResponses, ) @@ -148,14 +116,3 @@ DO UPDATE SET "context_menu_permission_level" = $2; return } -func (s *SettingsTable) SetOverflow(ctx context.Context, guildId uint64, enabled bool, categoryId *uint64) (err error) { - query := ` -INSERT INTO settings("guild_id", "overflow_enabled", "overflow_category_id") -VALUES($1, $2, $3) -ON CONFLICT("guild_id") -DO UPDATE SET "overflow_enabled" = $2, "overflow_category_id" = $3; -` - - _, err = s.Exec(ctx, query, guildId, enabled, categoryId) - return -} diff --git a/usercanclose.go b/usercanclose.go deleted file mode 100644 index e3b843b..0000000 --- a/usercanclose.go +++ /dev/null @@ -1,43 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type UsersCanClose struct { - *pgxpool.Pool -} - -func newUsersCanClose(db *pgxpool.Pool) *UsersCanClose { - return &UsersCanClose{ - db, - } -} - -func (u UsersCanClose) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS users_can_close( - "guild_id" int8 NOT NULL UNIQUE, - "users_can_close" bool NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (u *UsersCanClose) Get(ctx context.Context, guildId uint64) (usersCanClose bool, e error) { - if err := u.QueryRow(ctx, `SELECT "users_can_close" from users_can_close WHERE "guild_id" = $1;`, guildId).Scan(&usersCanClose); err != nil { - if err == pgx.ErrNoRows { - usersCanClose = true - } else { - e = err - } - } - - return -} - -func (u *UsersCanClose) Set(ctx context.Context, guildId uint64, usersCanClose bool) (err error) { - _, err = u.Exec(ctx, `INSERT INTO users_can_close("guild_id", "users_can_close") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "users_can_close" = $2;`, guildId, usersCanClose) - return -} From 68cf1320ab7b949ee937d1da6b5aa43383331137 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 14 May 2026 11:01:43 +0100 Subject: [PATCH 15/49] onboarding Signed-off-by: Ben --- auditlog.go | 4 ++ dashboardonboarding.go | 102 +++++++++++++++++++++++++++++++++++++++++ database.go | 3 ++ 3 files changed, 109 insertions(+) create mode 100644 dashboardonboarding.go diff --git a/auditlog.go b/auditlog.go index 609f85b..a91eb8b 100644 --- a/auditlog.go +++ b/auditlog.go @@ -124,6 +124,9 @@ const ( AuditActionGalleryReject AuditActionType = 402 AuditActionGalleryRemove AuditActionType = 403 AuditActionGalleryImport AuditActionType = 404 + + AuditActionOnboardingComplete AuditActionType = 410 + AuditActionOnboardingSkip AuditActionType = 411 ) type AuditResourceType int16 @@ -159,6 +162,7 @@ const ( AuditResourceKBCategory AuditResourceType = 28 AuditResourceKBSettings AuditResourceType = 29 AuditResourceGalleryListing AuditResourceType = 30 + AuditResourceOnboarding AuditResourceType = 31 ) type AuditLogEntry struct { diff --git a/dashboardonboarding.go b/dashboardonboarding.go new file mode 100644 index 0000000..0a378b9 --- /dev/null +++ b/dashboardonboarding.go @@ -0,0 +1,102 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type DashboardOnboarding struct { + GuildId uint64 `json:"guild_id,string"` + OnboardingCompleted bool `json:"onboarding_completed"` + OnboardingCompletedAt *time.Time `json:"onboarding_completed_at"` + CurrentStep int16 `json:"current_step"` + Skipped bool `json:"skipped"` +} + +type DashboardOnboardingTable struct { + *pgxpool.Pool +} + +func newDashboardOnboardingTable(pool *pgxpool.Pool) *DashboardOnboardingTable { + return &DashboardOnboardingTable{ + pool, + } +} + +func (t DashboardOnboardingTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS dashboard_onboarding( + "guild_id" INT8 NOT NULL, + "onboarding_completed" BOOL NOT NULL DEFAULT false, + "onboarding_completed_at" TIMESTAMPTZ DEFAULT NULL, + "current_step" INT2 NOT NULL DEFAULT 0, + "skipped" BOOL NOT NULL DEFAULT false, + PRIMARY KEY("guild_id") +); +` +} + +func (t *DashboardOnboardingTable) Get(ctx context.Context, guildId uint64) (DashboardOnboarding, error) { + query := ` +SELECT "onboarding_completed", "onboarding_completed_at", "current_step", "skipped" +FROM dashboard_onboarding +WHERE "guild_id" = $1; +` + + var onboarding DashboardOnboarding + onboarding.GuildId = guildId + + err := t.QueryRow(ctx, query, guildId).Scan( + &onboarding.OnboardingCompleted, + &onboarding.OnboardingCompletedAt, + &onboarding.CurrentStep, + &onboarding.Skipped, + ) + + if err == pgx.ErrNoRows { + return DashboardOnboarding{GuildId: guildId}, nil + } else if err != nil { + return DashboardOnboarding{}, err + } + + return onboarding, nil +} + +func (t *DashboardOnboardingTable) SetStep(ctx context.Context, guildId uint64, step int16) error { + query := ` +INSERT INTO dashboard_onboarding("guild_id", "current_step") +VALUES($1, $2) +ON CONFLICT("guild_id") +DO UPDATE SET "current_step" = $2; +` + + _, err := t.Exec(ctx, query, guildId, step) + return err +} + +func (t *DashboardOnboardingTable) Complete(ctx context.Context, guildId uint64) error { + query := ` +INSERT INTO dashboard_onboarding("guild_id", "onboarding_completed", "onboarding_completed_at", "current_step") +VALUES($1, true, NOW(), 5) +ON CONFLICT("guild_id") +DO UPDATE SET "onboarding_completed" = true, "onboarding_completed_at" = NOW(), "current_step" = 5; +` + + _, err := t.Exec(ctx, query, guildId) + return err +} + +func (t *DashboardOnboardingTable) Skip(ctx context.Context, guildId uint64) error { + query := ` +INSERT INTO dashboard_onboarding("guild_id", "skipped") +VALUES($1, true) +ON CONFLICT("guild_id") +DO UPDATE SET "skipped" = true; +` + + _, err := t.Exec(ctx, query, guildId) + return err +} diff --git a/database.go b/database.go index 94b1e46..f47d394 100644 --- a/database.go +++ b/database.go @@ -34,6 +34,7 @@ type Database struct { CustomIntegrationSecretValues *CustomIntegrationSecretValuesTable CustomIntegrationSecrets *CustomIntegrationSecretsTable CustomColours *CustomColours + DashboardOnboarding *DashboardOnboardingTable DashboardUsers *DashboardUsersTable ArchiveDmMessages *ArchiveDmMessages DiscordEntitlements *DiscordEntitlements @@ -143,6 +144,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { CustomIntegrationSecretValues: newCustomIntegrationSecretValuesTable(pool), CustomIntegrationSecrets: newCustomIntegrationSecretsTable(pool), CustomColours: newCustomColours(pool), + DashboardOnboarding: newDashboardOnboardingTable(pool), DashboardUsers: newDashboardUsersTable(pool), ArchiveDmMessages: newArchiveDmMessages(pool), DiscordEntitlements: newDiscordEntitlementsTable(pool), @@ -290,6 +292,7 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.GdprLogs, d.GlobalBlacklist, d.GuildLeaveTime, + d.DashboardOnboarding, d.GuildMetadata, d.LegacyPremiumEntitlements, d.LegacyPremiumEntitlementGuilds, From 4674ddeed1420e660652c77237d25b393d1e1b36 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Thu, 14 May 2026 21:45:25 +0200 Subject: [PATCH 16/49] Remove ThreadArchiveDuration field from Panel Remove the ThreadArchiveDuration property from the Panel struct and all related DB usages. This deletes the column from CREATE TABLE, removes it from SELECT/INSERT/UPDATE statements (and adjusts VALUES parameter counts), and drops the field from fieldPtrs and other references (including multipaneltargets). Updates keep parameter ordering and counts consistent after removal. --- multipaneltargets.go | 1 - panels.go | 30 ++++++++---------------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/multipaneltargets.go b/multipaneltargets.go index d8cbbde..ce036de 100644 --- a/multipaneltargets.go +++ b/multipaneltargets.go @@ -80,7 +80,6 @@ SELECT panels.hide_claim_button, panels.show_in_open_command, panels.store_transcripts, - panels.thread_archive_duration, panels.overflow_enabled, panels.overflow_category_id, panels.users_can_close, diff --git a/panels.go b/panels.go index 0a12ed3..dafb181 100644 --- a/panels.go +++ b/panels.go @@ -43,7 +43,6 @@ type Panel struct { HideClaimButton bool `json:"hide_claim_button"` ShowInOpenCommand bool `json:"show_in_open_command"` StoreTranscripts bool `json:"store_transcripts"` - ThreadArchiveDuration int `json:"thread_archive_duration"` OverflowEnabled bool `json:"overflow_enabled"` OverflowCategoryId *uint64 `json:"overflow_category_id,string"` UsersCanClose bool `json:"users_can_close"` @@ -106,7 +105,6 @@ CREATE TABLE IF NOT EXISTS panels( "hide_claim_button" bool NOT NULL DEFAULT false, "show_in_open_command" bool NOT NULL DEFAULT false, "store_transcripts" bool NOT NULL DEFAULT true, - "thread_archive_duration" int NOT NULL DEFAULT 10080, "overflow_enabled" bool NOT NULL DEFAULT false, "overflow_category_id" int8 DEFAULT NULL, "users_can_close" bool NOT NULL DEFAULT true, @@ -163,7 +161,6 @@ SELECT hide_claim_button, show_in_open_command, store_transcripts, - thread_archive_duration, overflow_enabled, overflow_category_id, users_can_close, @@ -220,7 +217,6 @@ SELECT hide_claim_button, show_in_open_command, store_transcripts, - thread_archive_duration, overflow_enabled, overflow_category_id, users_can_close, @@ -277,7 +273,6 @@ SELECT panels.hide_claim_button, panels.show_in_open_command, panels.store_transcripts, - panels.thread_archive_duration, panels.overflow_enabled, panels.overflow_category_id, panels.users_can_close, @@ -396,7 +391,6 @@ SELECT hide_claim_button, show_in_open_command, store_transcripts, - thread_archive_duration, overflow_enabled, overflow_category_id, users_can_close, @@ -456,7 +450,6 @@ SELECT hide_claim_button, show_in_open_command, store_transcripts, - thread_archive_duration, overflow_enabled, overflow_category_id, users_can_close, @@ -569,7 +562,6 @@ SELECT hide_claim_button, show_in_open_command, store_transcripts, - thread_archive_duration, overflow_enabled, overflow_category_id, users_can_close, @@ -636,7 +628,6 @@ SELECT panels.hide_claim_button, panels.show_in_open_command, panels.store_transcripts, - panels.thread_archive_duration, panels.overflow_enabled, panels.overflow_category_id, panels.users_can_close, @@ -772,7 +763,6 @@ INSERT INTO panels( "hide_claim_button", "show_in_open_command", "store_transcripts", - "thread_archive_duration", "overflow_enabled", "overflow_category_id", "users_can_close", @@ -781,7 +771,7 @@ INSERT INTO panels( "support_can_view", "support_can_type" ) -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41) +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40) ON CONFLICT("message_id") DO NOTHING RETURNING "panel_id";` @@ -819,7 +809,6 @@ RETURNING "panel_id";` panel.HideClaimButton, panel.ShowInOpenCommand, panel.StoreTranscripts, - panel.ThreadArchiveDuration, panel.OverflowEnabled, panel.OverflowCategoryId, panel.UsersCanClose, @@ -882,14 +871,13 @@ UPDATE panels "hide_claim_button" = $31, "show_in_open_command" = $32, "store_transcripts" = $33, - "thread_archive_duration" = $34, - "overflow_enabled" = $35, - "overflow_category_id" = $36, - "users_can_close" = $37, - "close_confirmation" = $38, - "feedback_enabled" = $39, - "support_can_view" = $40, - "support_can_type" = $41 + "overflow_enabled" = $34, + "overflow_category_id" = $35, + "users_can_close" = $36, + "close_confirmation" = $37, + "feedback_enabled" = $38, + "support_can_view" = $39, + "support_can_type" = $40 WHERE "panel_id" = $1 ;` @@ -928,7 +916,6 @@ UPDATE panels panel.HideClaimButton, panel.ShowInOpenCommand, panel.StoreTranscripts, - panel.ThreadArchiveDuration, panel.OverflowEnabled, panel.OverflowCategoryId, panel.UsersCanClose, @@ -1067,7 +1054,6 @@ func (p *Panel) fieldPtrs() []interface{} { &p.HideClaimButton, &p.ShowInOpenCommand, &p.StoreTranscripts, - &p.ThreadArchiveDuration, &p.OverflowEnabled, &p.OverflowCategoryId, &p.UsersCanClose, From b0f97fe936b5f5b12a2f864ba52cd3695cba3d32 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Fri, 15 May 2026 16:25:40 +0200 Subject: [PATCH 17/49] Replace DeleteMentions with MentionBehaviour Rename the Panel field DeleteMentions (bool) to MentionBehaviour (string) and update all SQL schema/queries and Go mappings accordingly. The DB column is now mention_behaviour varchar(20) DEFAULT 'none', and all SELECT/INSERT/UPDATE statements and fieldPtrs were updated to use the new name. Also fixed a query to join forms by custom_id instead of form_id and added several panel fields to the SELECT (store_transcripts, overflow_enabled, overflow_category_id, users_can_close, close_confirmation, feedback_enabled, support_can_view, support_can_type). This change allows representing mention handling as a behaviour string rather than a boolean. --- multipaneltargets.go | 2 +- panels.go | 42 +++++++++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/multipaneltargets.go b/multipaneltargets.go index ce036de..b4d9766 100644 --- a/multipaneltargets.go +++ b/multipaneltargets.go @@ -69,7 +69,7 @@ SELECT panels.disabled, panels.exit_survey_form_id, panels.pending_category, - panels.delete_mentions, + panels.mention_behaviour, panels.transcript_channel_id, panels.use_threads, panels.ticket_notification_channel, diff --git a/panels.go b/panels.go index dafb181..742d627 100644 --- a/panels.go +++ b/panels.go @@ -32,7 +32,7 @@ type Panel struct { Disabled bool `json:"disabled"` ExitSurveyFormId *int `json:"exit_survey_form_id"` PendingCategory *uint64 `json:"pending_category,string"` - DeleteMentions bool `json:"delete_mentions"` + MentionBehaviour string `json:"mention_behaviour"` TranscriptChannelId *uint64 `json:"transcript_channel_id,string,omitempty"` UseThreads bool `json:"use_threads"` TicketNotificationChannel *uint64 `json:"ticket_notification_channel,string,omitempty"` @@ -94,7 +94,7 @@ CREATE TABLE IF NOT EXISTS panels( "disabled" bool NOT NULL DEFAULT false, "exit_survey_form_id" int DEFAULT NULL, "pending_category" int8 DEFAULT NULL, - "delete_mentions" bool NOT NULL DEFAULT false, + "mention_behaviour" varchar(20) NOT NULL DEFAULT 'none', "transcript_channel_id" int8 DEFAULT NULL, "use_threads" bool NOT NULL DEFAULT false, "ticket_notification_channel" int8 DEFAULT NULL, @@ -150,7 +150,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -206,7 +206,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -262,7 +262,7 @@ SELECT panels.disabled, panels.exit_survey_form_id, panels.pending_category, - panels.delete_mentions, + panels.mention_behaviour, panels.transcript_channel_id, panels.use_threads, panels.ticket_notification_channel, @@ -380,7 +380,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -439,7 +439,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -498,7 +498,7 @@ SELECT panels.disabled, panels.exit_survey_form_id, panels.pending_category, - panels.delete_mentions, + panels.mention_behaviour, panels.transcript_channel_id, panels.use_threads, panels.ticket_notification_channel, @@ -507,11 +507,19 @@ SELECT panels.hide_close_button, panels.hide_close_with_reason_button, panels.hide_claim_button, - panels.show_in_open_command + panels.show_in_open_command, + panels.store_transcripts, + panels.overflow_enabled, + panels.overflow_category_id, + panels.users_can_close, + panels.close_confirmation, + panels.feedback_enabled, + panels.support_can_view, + panels.support_can_type FROM panels INNER JOIN forms ON forms.form_id = panels.form_id -WHERE forms.guild_id = $1 AND forms.form_id = $2; +WHERE forms.guild_id = $1 AND forms.custom_id = $2; ` switch err := p.QueryRow(ctx, query, guildId, customId).Scan(panel.fieldPtrs()...); err { @@ -551,7 +559,7 @@ SELECT disabled, exit_survey_form_id, pending_category, - delete_mentions, + mention_behaviour, transcript_channel_id, use_threads, ticket_notification_channel, @@ -617,7 +625,7 @@ SELECT panels.disabled, panels.exit_survey_form_id, panels.pending_category, - panels.delete_mentions, + panels.mention_behaviour, panels.transcript_channel_id, panels.use_threads, panels.ticket_notification_channel, @@ -752,7 +760,7 @@ INSERT INTO panels( "disabled", "exit_survey_form_id", "pending_category", - "delete_mentions", + "mention_behaviour", "transcript_channel_id", "use_threads", "ticket_notification_channel", @@ -798,7 +806,7 @@ RETURNING "panel_id";` panel.Disabled, panel.ExitSurveyFormId, panel.PendingCategory, - panel.DeleteMentions, + panel.MentionBehaviour, panel.TranscriptChannelId, panel.UseThreads, panel.TicketNotificationChannel, @@ -860,7 +868,7 @@ UPDATE panels "disabled" = $20, "exit_survey_form_id" = $21, "pending_category" = $22, - "delete_mentions" = $23, + "mention_behaviour" = $23, "transcript_channel_id" = $24, "use_threads" = $25, "ticket_notification_channel" = $26, @@ -905,7 +913,7 @@ UPDATE panels panel.Disabled, panel.ExitSurveyFormId, panel.PendingCategory, - panel.DeleteMentions, + panel.MentionBehaviour, panel.TranscriptChannelId, panel.UseThreads, panel.TicketNotificationChannel, @@ -1043,7 +1051,7 @@ func (p *Panel) fieldPtrs() []interface{} { &p.Disabled, &p.ExitSurveyFormId, &p.PendingCategory, - &p.DeleteMentions, + &p.MentionBehaviour, &p.TranscriptChannelId, &p.UseThreads, &p.TicketNotificationChannel, From 6f43c8bebab1728c55634ea6db9fbf3190fbed91 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Fri, 15 May 2026 18:07:55 +0200 Subject: [PATCH 18/49] Remove legacy DB tables and add emoji_animated Remove several legacy database table wrappers (archivechannel.go, namingscheme.go, ticketlimit.go, ticketpermissions.go, welcomemessage.go) and strip their references from Database, NewDatabase and CreateTables so those tables are no longer initialized by the app. Add a TicketPermissions struct into panelticketpermissions.go (consolidating permission fields). Add an EmojiAnimated bool field to Panel (panels.go) and update the SQL schema, SELECT/INSERT/UPDATE statements, parameter ordering/counts and field pointers to include the new emoji_animated column (default false). This change consolidates permission code and introduces support for animated emojis in panels; note that the removed table wrappers mean those DB tables will no longer be managed by CreateTables and related helpers. --- archivechannel.go | 58 ------------------------ database.go | 15 ------- namingscheme.go | 56 ----------------------- panels.go | 79 +++++++++++++++++++------------- panelticketpermissions.go | 10 +++++ ticketlimit.go | 45 ------------------- ticketpermissions.go | 95 --------------------------------------- welcomemessage.go | 42 ----------------- 8 files changed, 57 insertions(+), 343 deletions(-) delete mode 100644 archivechannel.go delete mode 100644 namingscheme.go delete mode 100644 ticketlimit.go delete mode 100644 ticketpermissions.go delete mode 100644 welcomemessage.go diff --git a/archivechannel.go b/archivechannel.go deleted file mode 100644 index 75aae2c..0000000 --- a/archivechannel.go +++ /dev/null @@ -1,58 +0,0 @@ -package database - -import ( - "context" - - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type ArchiveChannel struct { - *pgxpool.Pool -} - -func newArchiveChannel(db *pgxpool.Pool) *ArchiveChannel { - return &ArchiveChannel{ - db, - } -} - -func (c ArchiveChannel) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS archive_channel( - "guild_id" int8 NOT NULL UNIQUE, - "channel_id" int8, - PRIMARY KEY("guild_id") -);` -} - -func (c *ArchiveChannel) Get(ctx context.Context, guildId uint64) (archiveChannel *uint64, e error) { - query := `SELECT "channel_id" from archive_channel WHERE "guild_id" = $1;` - - if err := c.QueryRow(ctx, query, guildId).Scan(&archiveChannel); err != nil && err != pgx.ErrNoRows { - e = err - } - - return -} - -func (c *ArchiveChannel) Set(ctx context.Context, guildId uint64, archiveChannel *uint64) (err error) { - query := ` -INSERT INTO archive_channel("guild_id", "channel_id") -VALUES($1, $2) -ON CONFLICT("guild_id") DO UPDATE SET "channel_id" = $2; -` - - _, err = c.Exec(ctx, query, guildId, archiveChannel) - return -} - -func (c *ArchiveChannel) DeleteByGuild(ctx context.Context, guildId uint64) (err error) { - _, err = c.Exec(ctx, `DELETE FROM archive_channel WHERE "guild_id" = $1;`, guildId) - return -} - -func (c *ArchiveChannel) DeleteByChannel(ctx context.Context, channelId uint64) (err error) { - _, err = c.Exec(ctx, `DELETE FROM archive_channel WHERE "channel_id" = $1;`, channelId) - return -} diff --git a/database.go b/database.go index 892ff7e..751b617 100644 --- a/database.go +++ b/database.go @@ -13,7 +13,6 @@ const defaultTransactionTimeout = time.Second * 3 type Database struct { pool *pgxpool.Pool ActiveLanguage *ActiveLanguage - ArchiveChannel *ArchiveChannel AuditLog *AuditLogTable ArchiveMessages *ArchiveMessages AutoCloseExclude *AutoCloseExclude @@ -62,7 +61,6 @@ type Database struct { MultiPanels *MultiPanelTable MultiPanelTargets *MultiPanelTargets MultiServerSkus *MultiServerSkus - NamingScheme *TicketNamingScheme OnCall *OnCall Panel *PanelTable PanelAccessControlRules *PanelAccessControlRules @@ -97,16 +95,13 @@ type Database struct { Tag *TagsTable TicketClaims *TicketClaims TicketLastMessage *TicketLastMessageTable - TicketLimit *TicketLimit TicketMembers *TicketMembers - TicketPermissions *TicketPermissionsTable Tickets *TicketTable UsedKeys *UsedKeys UserGuilds *UserGuildsTable VoteCredits *VoteCredits Votes *Votes Webhooks *WebhookTable - WelcomeMessages *WelcomeMessages TicketLabels *TicketLabelsTable TicketLabelAssignments *TicketLabelAssignmentsTable Whitelabel *WhitelabelBotTable @@ -120,7 +115,6 @@ func NewDatabase(pool *pgxpool.Pool) *Database { db := &Database{ pool: pool, ActiveLanguage: newActiveLanguage(pool), - ArchiveChannel: newArchiveChannel(pool), AuditLog: newAuditLogTable(pool), ArchiveMessages: newArchiveMessages(pool), AutoCloseExclude: newAutoCloseExclude(pool), @@ -169,7 +163,6 @@ func NewDatabase(pool *pgxpool.Pool) *Database { MultiPanels: newMultiMultiPanelTable(pool), MultiPanelTargets: newMultiPanelTargets(pool), MultiServerSkus: newMultiServerSkusTable(pool), - NamingScheme: newTicketNamingScheme(pool), OnCall: newOnCall(pool), Panel: newPanelTable(pool), PanelAccessControlRules: newPanelAccessControlRules(pool), @@ -204,16 +197,13 @@ func NewDatabase(pool *pgxpool.Pool) *Database { Tag: newTag(pool), TicketClaims: newTicketClaims(pool), TicketLastMessage: newTicketLastMessageTable(pool), - TicketLimit: newTicketLimit(pool), TicketMembers: newTicketMembers(pool), - TicketPermissions: newTicketPermissionsTable(pool), Tickets: newTicketTable(pool), UsedKeys: newUsedKeys(pool), UserGuilds: newUserGuildsTable(pool), VoteCredits: newVoteCreditsTable(pool), Votes: newVotes(pool), Webhooks: newWebhookTable(pool), - WelcomeMessages: newWelcomeMessages(pool), TicketLabels: newTicketLabelsTable(pool), TicketLabelAssignments: newTicketLabelAssignmentsTable(pool), Whitelabel: newWhitelabelBotTable(pool), @@ -253,7 +243,6 @@ func (d *Database) WithTx(ctx context.Context, f func(tx pgx.Tx) error) error { func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { mustCreate(ctx, pool, d.ActiveLanguage, - d.ArchiveChannel, d.Blacklist, d.BotStaff, d.ChannelCategory, @@ -289,7 +278,6 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.LegacyPremiumEntitlementGuilds, d.MultiPanels, d.MultiServerSkus, - d.NamingScheme, d.OnCall, d.Panel, d.PanelTicketPermissions, // must be created after panels table @@ -322,8 +310,6 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.KBArticles, // Knowledge base articles (references categories) d.KBSettings, // Knowledge base customisation settings d.PanelKBCategories, // Panel-to-KB-category associations - d.TicketLimit, - d.TicketPermissions, d.Tickets, // Must be created before members table d.TicketLastMessage, // Must be created after Tickets table d.Participants, // Must be created after Tickets table @@ -347,7 +333,6 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.VoteCredits, d.Votes, d.Webhooks, - d.WelcomeMessages, d.Whitelabel, d.WhitelabelErrors, d.WhitelabelGuilds, diff --git a/namingscheme.go b/namingscheme.go deleted file mode 100644 index c05031c..0000000 --- a/namingscheme.go +++ /dev/null @@ -1,56 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type NamingScheme string - -const ( - Id NamingScheme = "id" - Username NamingScheme = "username" -) - -type TicketNamingScheme struct { - *pgxpool.Pool -} - -func newTicketNamingScheme(db *pgxpool.Pool) *TicketNamingScheme { - return &TicketNamingScheme{ - db, - } -} - -func (t TicketNamingScheme) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS naming_scheme( - "guild_id" int8 NOT NULL UNIQUE, - "naming_scheme" varchar(16) NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (t *TicketNamingScheme) Get(ctx context.Context, guildId uint64) (ns NamingScheme, e error) { - query := `SELECT "naming_scheme" from naming_scheme WHERE "guild_id" = $1` - - var namingScheme string - if err := t.QueryRow(ctx, query, guildId).Scan(&namingScheme); err != nil && err != pgx.ErrNoRows { - e = err - } - - if namingScheme == "" { - ns = Id - } else { - ns = NamingScheme(namingScheme) - } - - return -} - -func (t *TicketNamingScheme) Set(ctx context.Context, guildId uint64, scheme NamingScheme) (err error) { - query := `INSERT INTO naming_scheme("guild_id", "naming_scheme") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "naming_scheme" = $2;` - _, err = t.Exec(ctx, query, guildId, scheme) - return -} diff --git a/panels.go b/panels.go index 742d627..094cff3 100644 --- a/panels.go +++ b/panels.go @@ -19,6 +19,7 @@ type Panel struct { TargetCategory uint64 `json:"category_id,string"` EmojiName *string `json:"emoji_name"` EmojiId *uint64 `json:"emoji_id,string"` + EmojiAnimated bool `json:"emoji_animated"` WelcomeMessageEmbed *int `json:"welcome_message_embed"` WithDefaultTeam bool `json:"default_team"` CustomId string `json:"custom_id"` @@ -81,6 +82,7 @@ CREATE TABLE IF NOT EXISTS panels( "target_category" int8 NOT NULL, "emoji_name" varchar(32) DEFAULT NULL, "emoji_id" int8 DEFAULT NULL, + "emoji_animated" bool NOT NULL DEFAULT false, "welcome_message" int NULL, "default_team" bool NOT NULL, "custom_id" varchar(100) NOT NULL, @@ -137,6 +139,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -193,6 +196,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -249,6 +253,7 @@ SELECT panels.target_category, panels.emoji_name, panels.emoji_id, + panels.emoji_animated, panels.welcome_message, panels.default_team, panels.custom_id, @@ -367,6 +372,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -426,6 +432,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -485,6 +492,7 @@ SELECT panels.target_category, panels.emoji_name, panels.emoji_id, + panels.emoji_animated, panels.welcome_message, panels.default_team, panels.custom_id, @@ -546,6 +554,7 @@ SELECT target_category, emoji_name, emoji_id, + emoji_animated, welcome_message, default_team, custom_id, @@ -612,6 +621,7 @@ SELECT panels.target_category, panels.emoji_name, panels.emoji_id, + panels.emoji_animated, panels.welcome_message, panels.default_team, panels.custom_id, @@ -747,6 +757,7 @@ INSERT INTO panels( "target_category", "emoji_name", "emoji_id", + "emoji_animated", "welcome_message", "default_team", "custom_id", @@ -779,7 +790,7 @@ INSERT INTO panels( "support_can_view", "support_can_type" ) -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40) +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41) ON CONFLICT("message_id") DO NOTHING RETURNING "panel_id";` @@ -793,6 +804,7 @@ RETURNING "panel_id";` panel.TargetCategory, panel.EmojiName, panel.EmojiId, + panel.EmojiAnimated, panel.WelcomeMessageEmbed, panel.WithDefaultTeam, panel.CustomId, @@ -855,37 +867,38 @@ UPDATE panels "target_category" = $7, "emoji_name" = $8, "emoji_id" = $9, - "welcome_message" = $10, - "default_team" = $11, - "custom_id" = $12, - "image_url" = $13, - "thumbnail_url" = $14, - "button_style" = $15, - "button_label" = $16, - "form_id" = $17, - "naming_scheme" = $18, - "force_disabled" = $19, - "disabled" = $20, - "exit_survey_form_id" = $21, - "pending_category" = $22, - "mention_behaviour" = $23, - "transcript_channel_id" = $24, - "use_threads" = $25, - "ticket_notification_channel" = $26, - "cooldown_seconds" = $27, - "ticket_limit" = $28, - "hide_close_button" = $29, - "hide_close_with_reason_button" = $30, - "hide_claim_button" = $31, - "show_in_open_command" = $32, - "store_transcripts" = $33, - "overflow_enabled" = $34, - "overflow_category_id" = $35, - "users_can_close" = $36, - "close_confirmation" = $37, - "feedback_enabled" = $38, - "support_can_view" = $39, - "support_can_type" = $40 + "emoji_animated" = $10, + "welcome_message" = $11, + "default_team" = $12, + "custom_id" = $13, + "image_url" = $14, + "thumbnail_url" = $15, + "button_style" = $16, + "button_label" = $17, + "form_id" = $18, + "naming_scheme" = $19, + "force_disabled" = $20, + "disabled" = $21, + "exit_survey_form_id" = $22, + "pending_category" = $23, + "mention_behaviour" = $24, + "transcript_channel_id" = $25, + "use_threads" = $26, + "ticket_notification_channel" = $27, + "cooldown_seconds" = $28, + "ticket_limit" = $29, + "hide_close_button" = $30, + "hide_close_with_reason_button" = $31, + "hide_claim_button" = $32, + "show_in_open_command" = $33, + "store_transcripts" = $34, + "overflow_enabled" = $35, + "overflow_category_id" = $36, + "users_can_close" = $37, + "close_confirmation" = $38, + "feedback_enabled" = $39, + "support_can_view" = $40, + "support_can_type" = $41 WHERE "panel_id" = $1 ;` @@ -900,6 +913,7 @@ UPDATE panels panel.TargetCategory, panel.EmojiName, panel.EmojiId, + panel.EmojiAnimated, panel.WelcomeMessageEmbed, panel.WithDefaultTeam, panel.CustomId, @@ -1038,6 +1052,7 @@ func (p *Panel) fieldPtrs() []interface{} { &p.TargetCategory, &p.EmojiName, &p.EmojiId, + &p.EmojiAnimated, &p.WelcomeMessageEmbed, &p.WithDefaultTeam, &p.CustomId, diff --git a/panelticketpermissions.go b/panelticketpermissions.go index 893ae68..e663a8b 100644 --- a/panelticketpermissions.go +++ b/panelticketpermissions.go @@ -7,6 +7,16 @@ import ( "github.com/jackc/pgx/v4/pgxpool" ) +type TicketPermissions struct { + AddReactions bool `json:"add_reactions"` + SendTTSMessages bool `json:"send_tts_messages"` + EmbedLinks bool `json:"embed_links"` + AttachFiles bool `json:"attach_files"` + UseExternalEmojis bool `json:"use_external_emojis"` + UseExternalStickers bool `json:"use_external_stickers"` + SendVoiceMessages bool `json:"send_voice_messages"` +} + type PanelTicketPermissionsTable struct { *pgxpool.Pool } diff --git a/ticketlimit.go b/ticketlimit.go deleted file mode 100644 index f43687d..0000000 --- a/ticketlimit.go +++ /dev/null @@ -1,45 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type TicketLimit struct { - *pgxpool.Pool -} - -func newTicketLimit(db *pgxpool.Pool) *TicketLimit { - return &TicketLimit{ - db, - } -} - -func (t TicketLimit) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS ticket_limit( - "guild_id" int8 NOT NULL UNIQUE, - "limit" int2 NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (t *TicketLimit) Get(ctx context.Context, guildId uint64) (limit uint8, e error) { - query := `SELECT "limit" from ticket_limit WHERE "guild_id" = $1;` - if err := t.QueryRow(ctx, query, guildId).Scan(&limit); err != nil { - if err == pgx.ErrNoRows { - limit = 5 - } else { - e = err - } - } - - return -} - -func (t *TicketLimit) Set(ctx context.Context, guildId uint64, limit uint8) (err error) { - query := `INSERT INTO ticket_limit("guild_id", "limit") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "limit" = $2;` - _, err = t.Exec(ctx, query, guildId, limit) - return -} diff --git a/ticketpermissions.go b/ticketpermissions.go deleted file mode 100644 index bace5ce..0000000 --- a/ticketpermissions.go +++ /dev/null @@ -1,95 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type TicketPermissions struct { - AddReactions bool `json:"add_reactions"` - SendTTSMessages bool `json:"send_tts_messages"` - EmbedLinks bool `json:"embed_links"` - AttachFiles bool `json:"attach_files"` - UseExternalEmojis bool `json:"use_external_emojis"` - UseExternalStickers bool `json:"use_external_stickers"` - SendVoiceMessages bool `json:"send_voice_messages"` -} - -type TicketPermissionsTable struct { - *pgxpool.Pool -} - -func newTicketPermissionsTable(db *pgxpool.Pool) *TicketPermissionsTable { - return &TicketPermissionsTable{ - db, - } -} - -func (c TicketPermissionsTable) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS ticket_permissions( - "guild_id" int8 NOT NULL, - "add_reactions" bool NOT NULL DEFAULT 't', - "send_tts_messages" bool NOT NULL DEFAULT 't', - "embed_links" bool NOT NULL DEFAULT 't', - "attach_files" bool NOT NULL DEFAULT 't', - "use_external_emojis" bool NOT NULL DEFAULT 't', - "use_external_stickers" bool NOT NULL DEFAULT 't', - "send_voice_messages" bool NOT NULL DEFAULT 't', - PRIMARY KEY("guild_id") -); -` -} - -func (c *TicketPermissionsTable) Get(ctx context.Context, guildId uint64) (TicketPermissions, error) { - query := ` -SELECT "add_reactions", "send_tts_messages", "embed_links", "attach_files", "use_external_emojis", "use_external_stickers", "send_voice_messages" -FROM ticket_permissions -WHERE "guild_id" = $1;` - - var permissions TicketPermissions - err := c.QueryRow(ctx, query, guildId).Scan( - &permissions.AddReactions, - &permissions.SendTTSMessages, - &permissions.EmbedLinks, - &permissions.AttachFiles, - &permissions.UseExternalEmojis, - &permissions.UseExternalStickers, - &permissions.SendVoiceMessages, - ) - - if err != nil { - if err == pgx.ErrNoRows { - return TicketPermissions{ - AddReactions: true, - SendTTSMessages: true, - EmbedLinks: true, - AttachFiles: true, - UseExternalEmojis: true, - UseExternalStickers: true, - SendVoiceMessages: true, - }, nil - } else { - return TicketPermissions{}, err - } - } - - return permissions, nil -} - -func (c *TicketPermissionsTable) Set(ctx context.Context, guildId uint64, permissions TicketPermissions) (err error) { - query := ` -INSERT INTO ticket_permissions("guild_id", "add_reactions", "send_tts_messages", "embed_links", "attach_files", "use_external_emojis", "use_external_stickers", "send_voice_messages") -VALUES($1, $2, $3, $4, $5, $6, $7, $8) -ON CONFLICT("guild_id") DO UPDATE SET "add_reactions" = $2, "send_tts_messages" = $3, "embed_links" = $4, "attach_files" = $5, "use_external_emojis" = $6, "use_external_stickers" = $7, "send_voice_messages" = $8;` - - _, err = c.Exec(ctx, query, guildId, permissions.AddReactions, permissions.SendTTSMessages, permissions.EmbedLinks, permissions.AttachFiles, permissions.UseExternalEmojis, permissions.UseExternalStickers, permissions.SendVoiceMessages) - return -} - -func (c *TicketPermissionsTable) Delete(ctx context.Context, guildId uint64) error { - query := `DELETE FROM ticket_permissions WHERE "guild_id"=$1;` - _, err := c.Exec(ctx, query, guildId) - return err -} diff --git a/welcomemessage.go b/welcomemessage.go deleted file mode 100644 index ce308ef..0000000 --- a/welcomemessage.go +++ /dev/null @@ -1,42 +0,0 @@ -package database - -import ( - "context" - "github.com/jackc/pgx/v4" - "github.com/jackc/pgx/v4/pgxpool" -) - -type WelcomeMessages struct { - *pgxpool.Pool -} - -func newWelcomeMessages(db *pgxpool.Pool) *WelcomeMessages { - return &WelcomeMessages{ - db, - } -} - -func (w WelcomeMessages) Schema() string { - return ` -CREATE TABLE IF NOT EXISTS welcome_messages( - "guild_id" int8 NOT NULL UNIQUE, - "welcome_message" text NOT NULL, - PRIMARY KEY("guild_id") -);` -} - -func (w *WelcomeMessages) Get(ctx context.Context, guildId uint64) (welcomeMessage string, e error) { - query := `SELECT "welcome_message" from welcome_messages WHERE "guild_id" = $1;` - - if err := w.QueryRow(ctx, query, guildId).Scan(&welcomeMessage); err != nil && err != pgx.ErrNoRows { - e = err - } - - return -} - -func (w *WelcomeMessages) Set(ctx context.Context, guildId uint64, welcomeMessage string) (err error) { - query := `INSERT INTO welcome_messages("guild_id", "welcome_message") VALUES($1, $2) ON CONFLICT("guild_id") DO UPDATE SET "welcome_message" = $2;` - _, err = w.Exec(ctx, query, guildId, welcomeMessage) - return -} From 6d35a7247d835b7d8a2443d834e8e70338e513c8 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 17 May 2026 09:15:33 +0100 Subject: [PATCH 19/49] feat: aff & not Signed-off-by: Ben --- affiliatecodes.go | 167 +++++++++++ affiliatereferrals.go | 282 ++++++++++++++++++ auditlog.go | 23 ++ database.go | 18 ++ emailverificationcodes.go | 79 +++++ gallery_listings.go | 20 +- notificationpreferences.go | 80 +++++ notifications.go | 127 ++++++++ sql/affiliate_codes/count_all.sql | 3 + sql/affiliate_codes/create.sql | 3 + sql/affiliate_codes/get_by_code.sql | 5 + sql/affiliate_codes/get_by_id.sql | 5 + .../get_by_polar_discount_id.sql | 5 + sql/affiliate_codes/get_by_user_id.sql | 5 + sql/affiliate_codes/list_all.sql | 7 + sql/affiliate_codes/schema.sql | 21 ++ sql/affiliate_codes/set_polar_discount_id.sql | 3 + sql/affiliate_codes/update_rates.sql | 4 + sql/affiliate_codes/update_status.sql | 6 + .../count_by_affiliate_user_id.sql | 3 + sql/affiliate_referrals/count_flagged.sql | 3 + sql/affiliate_referrals/count_pending.sql | 5 + sql/affiliate_referrals/create.sql | 7 + .../flag_by_subscription_id.sql | 6 + sql/affiliate_referrals/get_by_id.sql | 7 + .../get_by_subscription_id.sql | 7 + .../list_by_affiliate_user_id.sql | 9 + sql/affiliate_referrals/list_flagged.sql | 9 + sql/affiliate_referrals/list_redeemable.sql | 11 + sql/affiliate_referrals/schema.sql | 22 ++ .../set_entitlement_id.sql | 3 + .../sum_redeemable_days.sql | 5 + sql/affiliate_referrals/sum_redeemed_days.sql | 4 + sql/affiliate_referrals/update_status.sql | 5 + .../void_by_subscription_id.sql | 6 + sql/email_verification_codes/delete.sql | 1 + .../get_by_user_id.sql | 3 + .../increment_attempts.sql | 1 + sql/email_verification_codes/schema.sql | 7 + sql/email_verification_codes/upsert.sql | 4 + .../get_by_user_id.sql | 3 + .../get_by_user_id_and_category.sql | 4 + sql/notification_preferences/schema.sql | 8 + sql/notification_preferences/upsert.sql | 4 + sql/notifications/count_by_user_id.sql | 4 + sql/notifications/count_unread_by_user_id.sql | 4 + sql/notifications/create.sql | 3 + sql/notifications/list_by_user_id.sql | 6 + sql/notifications/mark_all_as_read.sql | 4 + sql/notifications/mark_as_read.sql | 4 + sql/notifications/schema.sql | 12 + sql/user_emails/delete.sql | 1 + sql/user_emails/get_by_user_id.sql | 3 + sql/user_emails/schema.sql | 7 + sql/user_emails/set_verified.sql | 3 + sql/user_emails/upsert.sql | 4 + useremails.go | 76 +++++ 57 files changed, 1131 insertions(+), 10 deletions(-) create mode 100644 affiliatecodes.go create mode 100644 affiliatereferrals.go create mode 100644 emailverificationcodes.go create mode 100644 notificationpreferences.go create mode 100644 notifications.go create mode 100644 sql/affiliate_codes/count_all.sql create mode 100644 sql/affiliate_codes/create.sql create mode 100644 sql/affiliate_codes/get_by_code.sql create mode 100644 sql/affiliate_codes/get_by_id.sql create mode 100644 sql/affiliate_codes/get_by_polar_discount_id.sql create mode 100644 sql/affiliate_codes/get_by_user_id.sql create mode 100644 sql/affiliate_codes/list_all.sql create mode 100644 sql/affiliate_codes/schema.sql create mode 100644 sql/affiliate_codes/set_polar_discount_id.sql create mode 100644 sql/affiliate_codes/update_rates.sql create mode 100644 sql/affiliate_codes/update_status.sql create mode 100644 sql/affiliate_referrals/count_by_affiliate_user_id.sql create mode 100644 sql/affiliate_referrals/count_flagged.sql create mode 100644 sql/affiliate_referrals/count_pending.sql create mode 100644 sql/affiliate_referrals/create.sql create mode 100644 sql/affiliate_referrals/flag_by_subscription_id.sql create mode 100644 sql/affiliate_referrals/get_by_id.sql create mode 100644 sql/affiliate_referrals/get_by_subscription_id.sql create mode 100644 sql/affiliate_referrals/list_by_affiliate_user_id.sql create mode 100644 sql/affiliate_referrals/list_flagged.sql create mode 100644 sql/affiliate_referrals/list_redeemable.sql create mode 100644 sql/affiliate_referrals/schema.sql create mode 100644 sql/affiliate_referrals/set_entitlement_id.sql create mode 100644 sql/affiliate_referrals/sum_redeemable_days.sql create mode 100644 sql/affiliate_referrals/sum_redeemed_days.sql create mode 100644 sql/affiliate_referrals/update_status.sql create mode 100644 sql/affiliate_referrals/void_by_subscription_id.sql create mode 100644 sql/email_verification_codes/delete.sql create mode 100644 sql/email_verification_codes/get_by_user_id.sql create mode 100644 sql/email_verification_codes/increment_attempts.sql create mode 100644 sql/email_verification_codes/schema.sql create mode 100644 sql/email_verification_codes/upsert.sql create mode 100644 sql/notification_preferences/get_by_user_id.sql create mode 100644 sql/notification_preferences/get_by_user_id_and_category.sql create mode 100644 sql/notification_preferences/schema.sql create mode 100644 sql/notification_preferences/upsert.sql create mode 100644 sql/notifications/count_by_user_id.sql create mode 100644 sql/notifications/count_unread_by_user_id.sql create mode 100644 sql/notifications/create.sql create mode 100644 sql/notifications/list_by_user_id.sql create mode 100644 sql/notifications/mark_all_as_read.sql create mode 100644 sql/notifications/mark_as_read.sql create mode 100644 sql/notifications/schema.sql create mode 100644 sql/user_emails/delete.sql create mode 100644 sql/user_emails/get_by_user_id.sql create mode 100644 sql/user_emails/schema.sql create mode 100644 sql/user_emails/set_verified.sql create mode 100644 sql/user_emails/upsert.sql create mode 100644 useremails.go diff --git a/affiliatecodes.go b/affiliatecodes.go new file mode 100644 index 0000000..78b7724 --- /dev/null +++ b/affiliatecodes.go @@ -0,0 +1,167 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type AffiliateCodes struct { + *pgxpool.Pool +} + +type AffiliateCode struct { + Id uuid.UUID `json:"id"` + UserId uint64 `json:"user_id"` + Code string `json:"code"` + PolarDiscountId *string `json:"polar_discount_id"` + Status string `json:"status"` + DiscountBasisPoints int `json:"discount_basis_points"` + CreditPercentage *int `json:"credit_percentage"` + CreatedAt time.Time `json:"created_at"` + ApprovedAt *time.Time `json:"approved_at"` + ApprovedBy *uint64 `json:"approved_by"` + RevokedAt *time.Time `json:"revoked_at"` +} + +var ( + //go:embed sql/affiliate_codes/schema.sql + affiliateCodesSchema string + + //go:embed sql/affiliate_codes/create.sql + affiliateCodesCreate string + + //go:embed sql/affiliate_codes/get_by_id.sql + affiliateCodesGetById string + + //go:embed sql/affiliate_codes/get_by_code.sql + affiliateCodesGetByCode string + + //go:embed sql/affiliate_codes/get_by_user_id.sql + affiliateCodesGetByUserId string + + //go:embed sql/affiliate_codes/get_by_polar_discount_id.sql + affiliateCodesGetByPolarDiscountId string + + //go:embed sql/affiliate_codes/list_all.sql + affiliateCodesListAll string + + //go:embed sql/affiliate_codes/count_all.sql + affiliateCodesCountAll string + + //go:embed sql/affiliate_codes/update_status.sql + affiliateCodesUpdateStatus string + + //go:embed sql/affiliate_codes/set_polar_discount_id.sql + affiliateCodesSetPolarDiscountId string + + //go:embed sql/affiliate_codes/update_rates.sql + affiliateCodesUpdateRates string +) + +func newAffiliateCodes(db *pgxpool.Pool) *AffiliateCodes { + return &AffiliateCodes{db} +} + +func (AffiliateCodes) Schema() string { + return affiliateCodesSchema +} + +func (t *AffiliateCodes) Create(ctx context.Context, userId uint64, code, status string, discountBasisPoints int, creditPercentage *int) (AffiliateCode, error) { + var id uuid.UUID + var createdAt time.Time + if err := t.QueryRow(ctx, affiliateCodesCreate, userId, code, status, discountBasisPoints, creditPercentage).Scan(&id, &createdAt); err != nil { + return AffiliateCode{}, err + } + + return AffiliateCode{ + Id: id, + UserId: userId, + Code: code, + Status: status, + DiscountBasisPoints: discountBasisPoints, + CreditPercentage: creditPercentage, + CreatedAt: createdAt, + }, nil +} + +func (t *AffiliateCodes) scanRow(row pgx.Row) (*AffiliateCode, error) { + var ac AffiliateCode + if err := row.Scan( + &ac.Id, &ac.UserId, &ac.Code, &ac.PolarDiscountId, &ac.Status, + &ac.DiscountBasisPoints, &ac.CreditPercentage, + &ac.CreatedAt, &ac.ApprovedAt, &ac.ApprovedBy, &ac.RevokedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &ac, nil +} + +func (t *AffiliateCodes) GetById(ctx context.Context, id uuid.UUID) (*AffiliateCode, error) { + return t.scanRow(t.QueryRow(ctx, affiliateCodesGetById, id)) +} + +func (t *AffiliateCodes) GetByCode(ctx context.Context, code string) (*AffiliateCode, error) { + return t.scanRow(t.QueryRow(ctx, affiliateCodesGetByCode, code)) +} + +func (t *AffiliateCodes) GetByUserId(ctx context.Context, userId uint64) (*AffiliateCode, error) { + return t.scanRow(t.QueryRow(ctx, affiliateCodesGetByUserId, userId)) +} + +func (t *AffiliateCodes) GetByPolarDiscountId(ctx context.Context, polarDiscountId string) (*AffiliateCode, error) { + return t.scanRow(t.QueryRow(ctx, affiliateCodesGetByPolarDiscountId, polarDiscountId)) +} + +func (t *AffiliateCodes) ListAll(ctx context.Context, status *string, limit, offset int) ([]AffiliateCode, error) { + rows, err := t.Query(ctx, affiliateCodesListAll, status, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var codes []AffiliateCode + for rows.Next() { + var ac AffiliateCode + if err := rows.Scan( + &ac.Id, &ac.UserId, &ac.Code, &ac.PolarDiscountId, &ac.Status, + &ac.DiscountBasisPoints, &ac.CreditPercentage, + &ac.CreatedAt, &ac.ApprovedAt, &ac.ApprovedBy, &ac.RevokedAt, + ); err != nil { + return nil, err + } + codes = append(codes, ac) + } + return codes, nil +} + +func (t *AffiliateCodes) CountAll(ctx context.Context, status *string) (int, error) { + var count int + if err := t.QueryRow(ctx, affiliateCodesCountAll, status).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *AffiliateCodes) UpdateStatus(ctx context.Context, id uuid.UUID, status string, approvedBy *uint64) error { + _, err := t.Exec(ctx, affiliateCodesUpdateStatus, id, status, approvedBy) + return err +} + +func (t *AffiliateCodes) SetPolarDiscountId(ctx context.Context, id uuid.UUID, polarDiscountId string) error { + _, err := t.Exec(ctx, affiliateCodesSetPolarDiscountId, id, polarDiscountId) + return err +} + +func (t *AffiliateCodes) UpdateRates(ctx context.Context, id uuid.UUID, discountBasisPoints int, creditPercentage *int) error { + _, err := t.Exec(ctx, affiliateCodesUpdateRates, id, discountBasisPoints, creditPercentage) + return err +} diff --git a/affiliatereferrals.go b/affiliatereferrals.go new file mode 100644 index 0000000..c3e8548 --- /dev/null +++ b/affiliatereferrals.go @@ -0,0 +1,282 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type AffiliateReferrals struct { + *pgxpool.Pool +} + +type AffiliateReferral struct { + Id uuid.UUID `json:"id"` + AffiliateCodeId uuid.UUID `json:"affiliate_code_id"` + AffiliateUserId uint64 `json:"affiliate_user_id"` + ReferredUserId uint64 `json:"referred_user_id"` + PolarSubscriptionId string `json:"polar_subscription_id"` + ReferredTier string `json:"referred_tier"` + ReferredSkuId uuid.UUID `json:"referred_sku_id"` + PurchasedDays int `json:"purchased_days"` + CreditDays int `json:"credit_days"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + RedeemableAt time.Time `json:"redeemable_at"` + RedeemedAt *time.Time `json:"redeemed_at"` + EntitlementId *uuid.UUID `json:"entitlement_id"` + VoidedAt *time.Time `json:"voided_at"` + VoidedReason *string `json:"voided_reason"` +} + +var ( + //go:embed sql/affiliate_referrals/schema.sql + affiliateReferralsSchema string + + //go:embed sql/affiliate_referrals/create.sql + affiliateReferralsCreate string + + //go:embed sql/affiliate_referrals/get_by_id.sql + affiliateReferralsGetById string + + //go:embed sql/affiliate_referrals/get_by_subscription_id.sql + affiliateReferralsGetBySubscriptionId string + + //go:embed sql/affiliate_referrals/list_by_affiliate_user_id.sql + affiliateReferralsListByAffiliateUserId string + + //go:embed sql/affiliate_referrals/count_by_affiliate_user_id.sql + affiliateReferralsCountByAffiliateUserId string + + //go:embed sql/affiliate_referrals/list_redeemable.sql + affiliateReferralsListRedeemable string + + //go:embed sql/affiliate_referrals/update_status.sql + affiliateReferralsUpdateStatus string + + //go:embed sql/affiliate_referrals/set_entitlement_id.sql + affiliateReferralsSetEntitlementId string + + //go:embed sql/affiliate_referrals/void_by_subscription_id.sql + affiliateReferralsVoidBySubscriptionId string + + //go:embed sql/affiliate_referrals/flag_by_subscription_id.sql + affiliateReferralsFlagBySubscriptionId string + + //go:embed sql/affiliate_referrals/sum_redeemable_days.sql + affiliateReferralsSumRedeemableDays string + + //go:embed sql/affiliate_referrals/count_pending.sql + affiliateReferralsCountPending string + + //go:embed sql/affiliate_referrals/sum_redeemed_days.sql + affiliateReferralsSumRedeemedDays string + + //go:embed sql/affiliate_referrals/list_flagged.sql + affiliateReferralsListFlagged string + + //go:embed sql/affiliate_referrals/count_flagged.sql + affiliateReferralsCountFlagged string +) + +func newAffiliateReferrals(db *pgxpool.Pool) *AffiliateReferrals { + return &AffiliateReferrals{db} +} + +func (AffiliateReferrals) Schema() string { + return affiliateReferralsSchema +} + +func (t *AffiliateReferrals) scanRow(row pgx.Row) (*AffiliateReferral, error) { + var r AffiliateReferral + if err := row.Scan( + &r.Id, &r.AffiliateCodeId, &r.AffiliateUserId, &r.ReferredUserId, + &r.PolarSubscriptionId, &r.ReferredTier, &r.ReferredSkuId, + &r.PurchasedDays, &r.CreditDays, &r.Status, + &r.CreatedAt, &r.RedeemableAt, &r.RedeemedAt, + &r.EntitlementId, &r.VoidedAt, &r.VoidedReason, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &r, nil +} + +func (t *AffiliateReferrals) scanRows(rows pgx.Rows) ([]AffiliateReferral, error) { + defer rows.Close() + + var referrals []AffiliateReferral + for rows.Next() { + var r AffiliateReferral + if err := rows.Scan( + &r.Id, &r.AffiliateCodeId, &r.AffiliateUserId, &r.ReferredUserId, + &r.PolarSubscriptionId, &r.ReferredTier, &r.ReferredSkuId, + &r.PurchasedDays, &r.CreditDays, &r.Status, + &r.CreatedAt, &r.RedeemableAt, &r.RedeemedAt, + &r.EntitlementId, &r.VoidedAt, &r.VoidedReason, + ); err != nil { + return nil, err + } + referrals = append(referrals, r) + } + return referrals, nil +} + +func (t *AffiliateReferrals) Create( + ctx context.Context, + affiliateCodeId uuid.UUID, + affiliateUserId, referredUserId uint64, + polarSubscriptionId, referredTier string, + referredSkuId uuid.UUID, + purchasedDays, creditDays int, + status string, + redeemableAt time.Time, +) (AffiliateReferral, error) { + var id uuid.UUID + var createdAt time.Time + if err := t.QueryRow(ctx, affiliateReferralsCreate, + affiliateCodeId, affiliateUserId, referredUserId, + polarSubscriptionId, referredTier, referredSkuId, + purchasedDays, creditDays, status, redeemableAt, + ).Scan(&id, &createdAt); err != nil { + return AffiliateReferral{}, err + } + + return AffiliateReferral{ + Id: id, + AffiliateCodeId: affiliateCodeId, + AffiliateUserId: affiliateUserId, + ReferredUserId: referredUserId, + PolarSubscriptionId: polarSubscriptionId, + ReferredTier: referredTier, + ReferredSkuId: referredSkuId, + PurchasedDays: purchasedDays, + CreditDays: creditDays, + Status: status, + CreatedAt: createdAt, + RedeemableAt: redeemableAt, + }, nil +} + +func (t *AffiliateReferrals) GetById(ctx context.Context, id uuid.UUID) (*AffiliateReferral, error) { + return t.scanRow(t.QueryRow(ctx, affiliateReferralsGetById, id)) +} + +func (t *AffiliateReferrals) GetBySubscriptionId(ctx context.Context, polarSubscriptionId string) (*AffiliateReferral, error) { + return t.scanRow(t.QueryRow(ctx, affiliateReferralsGetBySubscriptionId, polarSubscriptionId)) +} + +func (t *AffiliateReferrals) ListByAffiliateUserId(ctx context.Context, affiliateUserId uint64, limit, offset int) ([]AffiliateReferral, error) { + rows, err := t.Query(ctx, affiliateReferralsListByAffiliateUserId, affiliateUserId, limit, offset) + if err != nil { + return nil, err + } + return t.scanRows(rows) +} + +func (t *AffiliateReferrals) CountByAffiliateUserId(ctx context.Context, affiliateUserId uint64) (int, error) { + var count int + if err := t.QueryRow(ctx, affiliateReferralsCountByAffiliateUserId, affiliateUserId).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *AffiliateReferrals) ListRedeemable(ctx context.Context, tx pgx.Tx, affiliateUserId uint64) ([]AffiliateReferral, error) { + rows, err := tx.Query(ctx, affiliateReferralsListRedeemable, affiliateUserId) + if err != nil { + return nil, err + } + defer rows.Close() + + var referrals []AffiliateReferral + for rows.Next() { + var r AffiliateReferral + if err := rows.Scan( + &r.Id, &r.AffiliateCodeId, &r.AffiliateUserId, &r.ReferredUserId, + &r.PolarSubscriptionId, &r.ReferredTier, &r.ReferredSkuId, + &r.PurchasedDays, &r.CreditDays, &r.Status, + &r.CreatedAt, &r.RedeemableAt, &r.RedeemedAt, + &r.EntitlementId, &r.VoidedAt, &r.VoidedReason, + ); err != nil { + return nil, err + } + referrals = append(referrals, r) + } + return referrals, nil +} + +func (t *AffiliateReferrals) UpdateStatus(ctx context.Context, tx pgx.Tx, id uuid.UUID, status string) error { + _, err := tx.Exec(ctx, affiliateReferralsUpdateStatus, id, status) + return err +} + +func (t *AffiliateReferrals) SetEntitlementId(ctx context.Context, tx pgx.Tx, id uuid.UUID, entitlementId uuid.UUID) error { + _, err := tx.Exec(ctx, affiliateReferralsSetEntitlementId, id, entitlementId) + return err +} + +func (t *AffiliateReferrals) VoidBySubscriptionId(ctx context.Context, polarSubscriptionId, reason string) error { + _, err := t.Exec(ctx, affiliateReferralsVoidBySubscriptionId, polarSubscriptionId, reason) + return err +} + +func (t *AffiliateReferrals) FlagBySubscriptionId(ctx context.Context, polarSubscriptionId, reason string) error { + _, err := t.Exec(ctx, affiliateReferralsFlagBySubscriptionId, polarSubscriptionId, reason) + return err +} + +func (t *AffiliateReferrals) SumRedeemedByUser(ctx context.Context, affiliateUserId uint64) (int, error) { + var sum int + if err := t.QueryRow(ctx, affiliateReferralsSumRedeemedDays, affiliateUserId).Scan(&sum); err != nil { + return 0, err + } + return sum, nil +} + +func (t *AffiliateReferrals) SumRedeemedByUserTx(ctx context.Context, tx pgx.Tx, affiliateUserId uint64) (int, error) { + var sum int + if err := tx.QueryRow(ctx, affiliateReferralsSumRedeemedDays, affiliateUserId).Scan(&sum); err != nil { + return 0, err + } + return sum, nil +} + +func (t *AffiliateReferrals) ListFlagged(ctx context.Context, limit, offset int) ([]AffiliateReferral, error) { + rows, err := t.Query(ctx, affiliateReferralsListFlagged, limit, offset) + if err != nil { + return nil, err + } + return t.scanRows(rows) +} + +func (t *AffiliateReferrals) CountFlagged(ctx context.Context) (int, error) { + var count int + if err := t.QueryRow(ctx, affiliateReferralsCountFlagged).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *AffiliateReferrals) SumRedeemableByUser(ctx context.Context, affiliateUserId uint64) (int, error) { + var sum int + if err := t.QueryRow(ctx, affiliateReferralsSumRedeemableDays, affiliateUserId).Scan(&sum); err != nil { + return 0, err + } + return sum, nil +} + +func (t *AffiliateReferrals) CountPending(ctx context.Context, affiliateUserId uint64) (int, error) { + var count int + if err := t.QueryRow(ctx, affiliateReferralsCountPending, affiliateUserId).Scan(&count); err != nil { + return 0, err + } + return count, nil +} diff --git a/auditlog.go b/auditlog.go index a91eb8b..4561723 100644 --- a/auditlog.go +++ b/auditlog.go @@ -127,6 +127,23 @@ const ( AuditActionOnboardingComplete AuditActionType = 410 AuditActionOnboardingSkip AuditActionType = 411 + + AuditActionAffiliateApply AuditActionType = 420 + AuditActionAffiliateApprove AuditActionType = 421 + AuditActionAffiliateRevoke AuditActionType = 422 + AuditActionAffiliateCreate AuditActionType = 423 + AuditActionAffiliateRedeem AuditActionType = 424 + AuditActionAffiliateVoid AuditActionType = 425 + AuditActionAffiliateUpdateRate AuditActionType = 426 + + AuditActionNotificationPreferencesUpdate AuditActionType = 430 + AuditActionUserEmailUpdate AuditActionType = 431 + AuditActionUserEmailDelete AuditActionType = 432 + AuditActionNotificationMarkRead AuditActionType = 433 + AuditActionNotificationMarkAllRead AuditActionType = 434 + + AuditActionEmailVerify AuditActionType = 435 + AuditActionEmailResendVerification AuditActionType = 436 ) type AuditResourceType int16 @@ -163,6 +180,12 @@ const ( AuditResourceKBSettings AuditResourceType = 29 AuditResourceGalleryListing AuditResourceType = 30 AuditResourceOnboarding AuditResourceType = 31 + AuditResourceAffiliate AuditResourceType = 32 + AuditResourceAffiliateReferral AuditResourceType = 33 + AuditResourceNotificationPrefs AuditResourceType = 34 + AuditResourceUserEmail AuditResourceType = 35 + AuditResourceNotification AuditResourceType = 36 + AuditResourceUserEmailVerification AuditResourceType = 37 ) type AuditLogEntry struct { diff --git a/database.go b/database.go index 751b617..1effb05 100644 --- a/database.go +++ b/database.go @@ -12,6 +12,8 @@ const defaultTransactionTimeout = time.Second * 3 type Database struct { pool *pgxpool.Pool + AffiliateCodes *AffiliateCodes + AffiliateReferrals *AffiliateReferrals ActiveLanguage *ActiveLanguage AuditLog *AuditLogTable ArchiveMessages *ArchiveMessages @@ -36,6 +38,7 @@ type Database struct { ArchiveDmMessages *ArchiveDmMessages DiscordEntitlements *DiscordEntitlements DiscordStoreSkus *DiscordStoreSkus + EmailVerificationCodes *EmailVerificationCodes EmbedFields *EmbedFieldsTable Embeds *EmbedsTable Entitlements *Entitlements @@ -61,6 +64,8 @@ type Database struct { MultiPanels *MultiPanelTable MultiPanelTargets *MultiPanelTargets MultiServerSkus *MultiServerSkus + Notifications *NotificationsTable + NotificationPreferences *NotificationPreferencesTable OnCall *OnCall Panel *PanelTable PanelAccessControlRules *PanelAccessControlRules @@ -98,6 +103,7 @@ type Database struct { TicketMembers *TicketMembers Tickets *TicketTable UsedKeys *UsedKeys + UserEmails *UserEmails UserGuilds *UserGuildsTable VoteCredits *VoteCredits Votes *Votes @@ -114,6 +120,8 @@ type Database struct { func NewDatabase(pool *pgxpool.Pool) *Database { db := &Database{ pool: pool, + AffiliateCodes: newAffiliateCodes(pool), + AffiliateReferrals: newAffiliateReferrals(pool), ActiveLanguage: newActiveLanguage(pool), AuditLog: newAuditLogTable(pool), ArchiveMessages: newArchiveMessages(pool), @@ -138,6 +146,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { ArchiveDmMessages: newArchiveDmMessages(pool), DiscordEntitlements: newDiscordEntitlementsTable(pool), DiscordStoreSkus: newDiscordStoreSkusTable(pool), + EmailVerificationCodes: newEmailVerificationCodes(pool), EmbedFields: newEmbedFieldsTable(pool), Embeds: newEmbedsTable(pool), Entitlements: newEntitlementsTable(pool), @@ -163,6 +172,8 @@ func NewDatabase(pool *pgxpool.Pool) *Database { MultiPanels: newMultiMultiPanelTable(pool), MultiPanelTargets: newMultiPanelTargets(pool), MultiServerSkus: newMultiServerSkusTable(pool), + Notifications: newNotificationsTable(pool), + NotificationPreferences: newNotificationPreferencesTable(pool), OnCall: newOnCall(pool), Panel: newPanelTable(pool), PanelAccessControlRules: newPanelAccessControlRules(pool), @@ -200,6 +211,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { TicketMembers: newTicketMembers(pool), Tickets: newTicketTable(pool), UsedKeys: newUsedKeys(pool), + UserEmails: newUserEmails(pool), UserGuilds: newUserGuildsTable(pool), VoteCredits: newVoteCreditsTable(pool), Votes: newVotes(pool), @@ -292,6 +304,8 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.PatreonEntitlements, d.PolarEntitlements, // depends on entitlements d.PolarProducts, // depends on skus + d.AffiliateCodes, // adds 'affiliate' to premium_source enum; must be after entitlements + d.AffiliateReferrals, // depends on affiliate_codes d.Permissions, d.PremiumGuilds, d.PremiumKeys, @@ -329,6 +343,10 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.TicketMembers, d.TicketClaims, d.UsedKeys, + d.UserEmails, + d.EmailVerificationCodes, + d.Notifications, + d.NotificationPreferences, d.UserGuilds, d.VoteCredits, d.Votes, diff --git a/emailverificationcodes.go b/emailverificationcodes.go new file mode 100644 index 0000000..c06a7ae --- /dev/null +++ b/emailverificationcodes.go @@ -0,0 +1,79 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type EmailVerificationCodes struct { + *pgxpool.Pool +} + +type EmailVerificationCode struct { + UserId uint64 `json:"user_id"` + Code string `json:"code"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + FailedAttempts int `json:"failed_attempts"` +} + +var ( + //go:embed sql/email_verification_codes/schema.sql + emailVerificationCodesSchema string + + //go:embed sql/email_verification_codes/upsert.sql + emailVerificationCodesUpsert string + + //go:embed sql/email_verification_codes/get_by_user_id.sql + emailVerificationCodesGetByUserId string + + //go:embed sql/email_verification_codes/delete.sql + emailVerificationCodesDelete string + + //go:embed sql/email_verification_codes/increment_attempts.sql + emailVerificationCodesIncrementAttempts string +) + +func newEmailVerificationCodes(db *pgxpool.Pool) *EmailVerificationCodes { + return &EmailVerificationCodes{db} +} + +func (EmailVerificationCodes) Schema() string { + return emailVerificationCodesSchema +} + +func (t *EmailVerificationCodes) Upsert(ctx context.Context, userId uint64, code string, expiresAt time.Time) error { + _, err := t.Exec(ctx, emailVerificationCodesUpsert, userId, code, expiresAt) + return err +} + +func (t *EmailVerificationCodes) GetByUserId(ctx context.Context, userId uint64) (*EmailVerificationCode, error) { + var vc EmailVerificationCode + if err := t.QueryRow(ctx, emailVerificationCodesGetByUserId, userId).Scan( + &vc.UserId, &vc.Code, &vc.ExpiresAt, &vc.CreatedAt, &vc.FailedAttempts, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &vc, nil +} + +func (t *EmailVerificationCodes) Delete(ctx context.Context, userId uint64) error { + _, err := t.Exec(ctx, emailVerificationCodesDelete, userId) + return err +} + +func (t *EmailVerificationCodes) IncrementAttempts(ctx context.Context, userId uint64) (int, error) { + var count int + if err := t.QueryRow(ctx, emailVerificationCodesIncrementAttempts, userId).Scan(&count); err != nil { + return 0, err + } + return count, nil +} diff --git a/gallery_listings.go b/gallery_listings.go index c4b4727..c02bd72 100644 --- a/gallery_listings.go +++ b/gallery_listings.go @@ -39,16 +39,16 @@ type GalleryListing struct { ImportCount int `json:"import_count"` Featured bool `json:"featured"` SnapshotData []byte `json:"snapshot_data"` - // Panel visual design fields — only populated for panel listings - Title string `json:"title"` - Content string `json:"content"` - Colour int32 `json:"colour"` - ImageUrl *string `json:"image_url"` - ThumbnailUrl *string `json:"thumbnail_url"` - ButtonStyle *int16 `json:"button_style"` - ButtonLabel string `json:"button_label"` - EmojiName *string `json:"emoji_name"` - WelcomeMessage []byte `json:"welcome_message"` + // Panel visual design fields - only populated for panel listings + Title string `json:"title"` + Content string `json:"content"` + Colour int32 `json:"colour"` + ImageUrl *string `json:"image_url"` + ThumbnailUrl *string `json:"thumbnail_url"` + ButtonStyle *int16 `json:"button_style"` + ButtonLabel string `json:"button_label"` + EmojiName *string `json:"emoji_name"` + WelcomeMessage []byte `json:"welcome_message"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/notificationpreferences.go b/notificationpreferences.go new file mode 100644 index 0000000..cc45699 --- /dev/null +++ b/notificationpreferences.go @@ -0,0 +1,80 @@ +package database + +import ( + "context" + _ "embed" + "errors" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type NotificationPreferencesTable struct { + *pgxpool.Pool +} + +type NotificationPreference struct { + UserId uint64 `json:"user_id"` + Category string `json:"category"` + DiscordDm bool `json:"discord_dm"` + Email bool `json:"email"` + InApp bool `json:"in_app"` +} + +var ( + //go:embed sql/notification_preferences/schema.sql + notificationPreferencesSchema string + + //go:embed sql/notification_preferences/get_by_user_id.sql + notificationPreferencesGetByUserId string + + //go:embed sql/notification_preferences/get_by_user_id_and_category.sql + notificationPreferencesGetByUserIdAndCategory string + + //go:embed sql/notification_preferences/upsert.sql + notificationPreferencesUpsert string +) + +func newNotificationPreferencesTable(pool *pgxpool.Pool) *NotificationPreferencesTable { + return &NotificationPreferencesTable{pool} +} + +func (NotificationPreferencesTable) Schema() string { + return notificationPreferencesSchema +} + +func (t *NotificationPreferencesTable) GetByUserId(ctx context.Context, userId uint64) ([]NotificationPreference, error) { + rows, err := t.Query(ctx, notificationPreferencesGetByUserId, userId) + if err != nil { + return nil, err + } + defer rows.Close() + + var prefs []NotificationPreference + for rows.Next() { + var p NotificationPreference + if err := rows.Scan(&p.UserId, &p.Category, &p.DiscordDm, &p.Email, &p.InApp); err != nil { + return nil, err + } + prefs = append(prefs, p) + } + return prefs, nil +} + +func (t *NotificationPreferencesTable) GetByUserIdAndCategory(ctx context.Context, userId uint64, category string) (*NotificationPreference, error) { + var p NotificationPreference + if err := t.QueryRow(ctx, notificationPreferencesGetByUserIdAndCategory, userId, category).Scan( + &p.UserId, &p.Category, &p.DiscordDm, &p.Email, &p.InApp, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +func (t *NotificationPreferencesTable) Upsert(ctx context.Context, userId uint64, category string, discordDm, email, inApp bool) error { + _, err := t.Exec(ctx, notificationPreferencesUpsert, userId, category, discordDm, email, inApp) + return err +} diff --git a/notifications.go b/notifications.go new file mode 100644 index 0000000..a216aff --- /dev/null +++ b/notifications.go @@ -0,0 +1,127 @@ +package database + +import ( + "context" + _ "embed" + "time" + + "github.com/jackc/pgx/v4/pgxpool" +) + +type NotificationsTable struct { + *pgxpool.Pool +} + +type Notification struct { + Id int64 `json:"id"` + UserId uint64 `json:"user_id"` + Category string `json:"category"` + Title string `json:"title"` + Body string `json:"body"` + Link *string `json:"link"` + Read bool `json:"read"` + CreatedAt time.Time `json:"created_at"` +} + +var ( + //go:embed sql/notifications/schema.sql + notificationsSchema string + + //go:embed sql/notifications/create.sql + notificationsCreate string + + //go:embed sql/notifications/list_by_user_id.sql + notificationsListByUserId string + + //go:embed sql/notifications/count_unread_by_user_id.sql + notificationsCountUnreadByUserId string + + //go:embed sql/notifications/count_by_user_id.sql + notificationsCountByUserId string + + //go:embed sql/notifications/mark_as_read.sql + notificationsMarkAsRead string + + //go:embed sql/notifications/mark_all_as_read.sql + notificationsMarkAllAsRead string +) + +func newNotificationsTable(pool *pgxpool.Pool) *NotificationsTable { + return &NotificationsTable{pool} +} + +func (NotificationsTable) Schema() string { + return notificationsSchema +} + +func (t *NotificationsTable) Create(ctx context.Context, userId uint64, category, title, body string, link *string) (Notification, error) { + if len(title) > 200 { + title = title[:200] + } + if len(body) > 2000 { + body = body[:2000] + } + + var id int64 + var createdAt time.Time + if err := t.QueryRow(ctx, notificationsCreate, userId, category, title, body, link).Scan(&id, &createdAt); err != nil { + return Notification{}, err + } + + return Notification{ + Id: id, + UserId: userId, + Category: category, + Title: title, + Body: body, + Link: link, + Read: false, + CreatedAt: createdAt, + }, nil +} + +func (t *NotificationsTable) ListByUserId(ctx context.Context, userId uint64, category *string, limit, offset int) ([]Notification, error) { + rows, err := t.Query(ctx, notificationsListByUserId, userId, category, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var notifications []Notification + for rows.Next() { + var n Notification + if err := rows.Scan( + &n.Id, &n.UserId, &n.Category, &n.Title, &n.Body, &n.Link, &n.Read, &n.CreatedAt, + ); err != nil { + return nil, err + } + notifications = append(notifications, n) + } + return notifications, nil +} + +func (t *NotificationsTable) CountUnreadByUserId(ctx context.Context, userId uint64) (int, error) { + var count int + if err := t.QueryRow(ctx, notificationsCountUnreadByUserId, userId).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *NotificationsTable) CountByUserId(ctx context.Context, userId uint64, category *string) (int, error) { + var count int + if err := t.QueryRow(ctx, notificationsCountByUserId, userId, category).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (t *NotificationsTable) MarkAsRead(ctx context.Context, id int64, userId uint64) error { + _, err := t.Exec(ctx, notificationsMarkAsRead, id, userId) + return err +} + +func (t *NotificationsTable) MarkAllAsRead(ctx context.Context, userId uint64) error { + _, err := t.Exec(ctx, notificationsMarkAllAsRead, userId) + return err +} diff --git a/sql/affiliate_codes/count_all.sql b/sql/affiliate_codes/count_all.sql new file mode 100644 index 0000000..ef915ce --- /dev/null +++ b/sql/affiliate_codes/count_all.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) +FROM affiliate_codes +WHERE ($1::TEXT IS NULL OR "status" = $1); diff --git a/sql/affiliate_codes/create.sql b/sql/affiliate_codes/create.sql new file mode 100644 index 0000000..4f34205 --- /dev/null +++ b/sql/affiliate_codes/create.sql @@ -0,0 +1,3 @@ +INSERT INTO affiliate_codes ("user_id", "code", "status", "discount_basis_points", "credit_percentage") +VALUES ($1, $2, $3, $4, $5) +RETURNING "id", "created_at"; diff --git a/sql/affiliate_codes/get_by_code.sql b/sql/affiliate_codes/get_by_code.sql new file mode 100644 index 0000000..d7ba3f9 --- /dev/null +++ b/sql/affiliate_codes/get_by_code.sql @@ -0,0 +1,5 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE "code" = $1; diff --git a/sql/affiliate_codes/get_by_id.sql b/sql/affiliate_codes/get_by_id.sql new file mode 100644 index 0000000..b948be0 --- /dev/null +++ b/sql/affiliate_codes/get_by_id.sql @@ -0,0 +1,5 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE "id" = $1; diff --git a/sql/affiliate_codes/get_by_polar_discount_id.sql b/sql/affiliate_codes/get_by_polar_discount_id.sql new file mode 100644 index 0000000..a1b136c --- /dev/null +++ b/sql/affiliate_codes/get_by_polar_discount_id.sql @@ -0,0 +1,5 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE "polar_discount_id" = $1; diff --git a/sql/affiliate_codes/get_by_user_id.sql b/sql/affiliate_codes/get_by_user_id.sql new file mode 100644 index 0000000..699d1b7 --- /dev/null +++ b/sql/affiliate_codes/get_by_user_id.sql @@ -0,0 +1,5 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE "user_id" = $1; diff --git a/sql/affiliate_codes/list_all.sql b/sql/affiliate_codes/list_all.sql new file mode 100644 index 0000000..75950f7 --- /dev/null +++ b/sql/affiliate_codes/list_all.sql @@ -0,0 +1,7 @@ +SELECT "id", "user_id", "code", "polar_discount_id", "status", + "discount_basis_points", "credit_percentage", + "created_at", "approved_at", "approved_by", "revoked_at" +FROM affiliate_codes +WHERE ($1::TEXT IS NULL OR "status" = $1) +ORDER BY "created_at" DESC +LIMIT $2 OFFSET $3; diff --git a/sql/affiliate_codes/schema.sql b/sql/affiliate_codes/schema.sql new file mode 100644 index 0000000..0d0be4c --- /dev/null +++ b/sql/affiliate_codes/schema.sql @@ -0,0 +1,21 @@ +DO $$ BEGIN + ALTER TYPE premium_source ADD VALUE IF NOT EXISTS 'affiliate'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS affiliate_codes ( + "id" UUID DEFAULT gen_random_uuid() PRIMARY KEY, + "user_id" INT8 NOT NULL UNIQUE, + "code" TEXT NOT NULL UNIQUE, + "polar_discount_id" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "discount_basis_points" INT NOT NULL DEFAULT 500, + "credit_percentage" INT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "approved_at" TIMESTAMPTZ, + "approved_by" INT8, + "revoked_at" TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_affiliate_codes_user_id ON affiliate_codes("user_id"); +CREATE INDEX IF NOT EXISTS idx_affiliate_codes_status ON affiliate_codes("status"); diff --git a/sql/affiliate_codes/set_polar_discount_id.sql b/sql/affiliate_codes/set_polar_discount_id.sql new file mode 100644 index 0000000..a1c3829 --- /dev/null +++ b/sql/affiliate_codes/set_polar_discount_id.sql @@ -0,0 +1,3 @@ +UPDATE affiliate_codes +SET "polar_discount_id" = $2 +WHERE "id" = $1; diff --git a/sql/affiliate_codes/update_rates.sql b/sql/affiliate_codes/update_rates.sql new file mode 100644 index 0000000..93e2b88 --- /dev/null +++ b/sql/affiliate_codes/update_rates.sql @@ -0,0 +1,4 @@ +UPDATE affiliate_codes +SET "discount_basis_points" = $2, + "credit_percentage" = $3 +WHERE "id" = $1; diff --git a/sql/affiliate_codes/update_status.sql b/sql/affiliate_codes/update_status.sql new file mode 100644 index 0000000..2f0ca5c --- /dev/null +++ b/sql/affiliate_codes/update_status.sql @@ -0,0 +1,6 @@ +UPDATE affiliate_codes +SET "status" = $2, + "approved_at" = CASE WHEN $2 = 'active' THEN NOW() ELSE "approved_at" END, + "approved_by" = CASE WHEN $2 = 'active' THEN $3 ELSE "approved_by" END, + "revoked_at" = CASE WHEN $2 = 'revoked' THEN NOW() ELSE "revoked_at" END +WHERE "id" = $1; diff --git a/sql/affiliate_referrals/count_by_affiliate_user_id.sql b/sql/affiliate_referrals/count_by_affiliate_user_id.sql new file mode 100644 index 0000000..930eefe --- /dev/null +++ b/sql/affiliate_referrals/count_by_affiliate_user_id.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1; diff --git a/sql/affiliate_referrals/count_flagged.sql b/sql/affiliate_referrals/count_flagged.sql new file mode 100644 index 0000000..49683d6 --- /dev/null +++ b/sql/affiliate_referrals/count_flagged.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) +FROM affiliate_referrals +WHERE "status" = 'flagged'; diff --git a/sql/affiliate_referrals/count_pending.sql b/sql/affiliate_referrals/count_pending.sql new file mode 100644 index 0000000..0ae3432 --- /dev/null +++ b/sql/affiliate_referrals/count_pending.sql @@ -0,0 +1,5 @@ +SELECT COUNT(*) +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 + AND "status" = 'pending' + AND "redeemable_at" > NOW(); diff --git a/sql/affiliate_referrals/create.sql b/sql/affiliate_referrals/create.sql new file mode 100644 index 0000000..3914b96 --- /dev/null +++ b/sql/affiliate_referrals/create.sql @@ -0,0 +1,7 @@ +INSERT INTO affiliate_referrals ( + "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", "redeemable_at" +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING "id", "created_at"; diff --git a/sql/affiliate_referrals/flag_by_subscription_id.sql b/sql/affiliate_referrals/flag_by_subscription_id.sql new file mode 100644 index 0000000..074b4f1 --- /dev/null +++ b/sql/affiliate_referrals/flag_by_subscription_id.sql @@ -0,0 +1,6 @@ +UPDATE affiliate_referrals +SET "status" = 'flagged', + "voided_at" = NOW(), + "voided_reason" = $2 +WHERE "polar_subscription_id" = $1 + AND "status" = 'redeemed'; diff --git a/sql/affiliate_referrals/get_by_id.sql b/sql/affiliate_referrals/get_by_id.sql new file mode 100644 index 0000000..6e55ae7 --- /dev/null +++ b/sql/affiliate_referrals/get_by_id.sql @@ -0,0 +1,7 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "id" = $1; diff --git a/sql/affiliate_referrals/get_by_subscription_id.sql b/sql/affiliate_referrals/get_by_subscription_id.sql new file mode 100644 index 0000000..1c83910 --- /dev/null +++ b/sql/affiliate_referrals/get_by_subscription_id.sql @@ -0,0 +1,7 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "polar_subscription_id" = $1; diff --git a/sql/affiliate_referrals/list_by_affiliate_user_id.sql b/sql/affiliate_referrals/list_by_affiliate_user_id.sql new file mode 100644 index 0000000..6c89001 --- /dev/null +++ b/sql/affiliate_referrals/list_by_affiliate_user_id.sql @@ -0,0 +1,9 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 +ORDER BY "created_at" DESC +LIMIT $2 OFFSET $3; diff --git a/sql/affiliate_referrals/list_flagged.sql b/sql/affiliate_referrals/list_flagged.sql new file mode 100644 index 0000000..30a1010 --- /dev/null +++ b/sql/affiliate_referrals/list_flagged.sql @@ -0,0 +1,9 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "status" = 'flagged' +ORDER BY "voided_at" DESC +LIMIT $1 OFFSET $2; diff --git a/sql/affiliate_referrals/list_redeemable.sql b/sql/affiliate_referrals/list_redeemable.sql new file mode 100644 index 0000000..6fb4f94 --- /dev/null +++ b/sql/affiliate_referrals/list_redeemable.sql @@ -0,0 +1,11 @@ +SELECT "id", "affiliate_code_id", "affiliate_user_id", "referred_user_id", + "polar_subscription_id", "referred_tier", "referred_sku_id", + "purchased_days", "credit_days", "status", + "created_at", "redeemable_at", "redeemed_at", + "entitlement_id", "voided_at", "voided_reason" +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 + AND "status" IN ('pending', 'redeemable') + AND "redeemable_at" <= NOW() +ORDER BY "created_at" ASC +FOR UPDATE; diff --git a/sql/affiliate_referrals/schema.sql b/sql/affiliate_referrals/schema.sql new file mode 100644 index 0000000..19fb179 --- /dev/null +++ b/sql/affiliate_referrals/schema.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS affiliate_referrals ( + "id" UUID DEFAULT gen_random_uuid() PRIMARY KEY, + "affiliate_code_id" UUID NOT NULL REFERENCES affiliate_codes("id"), + "affiliate_user_id" INT8 NOT NULL, + "referred_user_id" INT8 NOT NULL, + "polar_subscription_id" TEXT NOT NULL UNIQUE, + "referred_tier" TEXT NOT NULL, + "referred_sku_id" UUID NOT NULL, + "purchased_days" INT NOT NULL, + "credit_days" INT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "redeemable_at" TIMESTAMPTZ NOT NULL, + "redeemed_at" TIMESTAMPTZ, + "entitlement_id" UUID, + "voided_at" TIMESTAMPTZ, + "voided_reason" TEXT +); + +CREATE INDEX IF NOT EXISTS idx_affiliate_referrals_affiliate_user_id ON affiliate_referrals("affiliate_user_id"); +CREATE INDEX IF NOT EXISTS idx_affiliate_referrals_status ON affiliate_referrals("status"); +CREATE INDEX IF NOT EXISTS idx_affiliate_referrals_redeemable_at ON affiliate_referrals("redeemable_at"); diff --git a/sql/affiliate_referrals/set_entitlement_id.sql b/sql/affiliate_referrals/set_entitlement_id.sql new file mode 100644 index 0000000..908cf49 --- /dev/null +++ b/sql/affiliate_referrals/set_entitlement_id.sql @@ -0,0 +1,3 @@ +UPDATE affiliate_referrals +SET "entitlement_id" = $2 +WHERE "id" = $1; diff --git a/sql/affiliate_referrals/sum_redeemable_days.sql b/sql/affiliate_referrals/sum_redeemable_days.sql new file mode 100644 index 0000000..3d3a566 --- /dev/null +++ b/sql/affiliate_referrals/sum_redeemable_days.sql @@ -0,0 +1,5 @@ +SELECT COALESCE(SUM("credit_days"), 0) +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 + AND "status" IN ('pending', 'redeemable') + AND "redeemable_at" <= NOW(); diff --git a/sql/affiliate_referrals/sum_redeemed_days.sql b/sql/affiliate_referrals/sum_redeemed_days.sql new file mode 100644 index 0000000..9dd0071 --- /dev/null +++ b/sql/affiliate_referrals/sum_redeemed_days.sql @@ -0,0 +1,4 @@ +SELECT COALESCE(SUM("credit_days"), 0) +FROM affiliate_referrals +WHERE "affiliate_user_id" = $1 + AND "status" = 'redeemed'; diff --git a/sql/affiliate_referrals/update_status.sql b/sql/affiliate_referrals/update_status.sql new file mode 100644 index 0000000..39facf4 --- /dev/null +++ b/sql/affiliate_referrals/update_status.sql @@ -0,0 +1,5 @@ +UPDATE affiliate_referrals +SET "status" = $2, + "redeemed_at" = CASE WHEN $2 = 'redeemed' THEN NOW() ELSE "redeemed_at" END, + "voided_at" = CASE WHEN $2 IN ('voided', 'flagged') THEN NOW() ELSE "voided_at" END +WHERE "id" = $1; diff --git a/sql/affiliate_referrals/void_by_subscription_id.sql b/sql/affiliate_referrals/void_by_subscription_id.sql new file mode 100644 index 0000000..decda68 --- /dev/null +++ b/sql/affiliate_referrals/void_by_subscription_id.sql @@ -0,0 +1,6 @@ +UPDATE affiliate_referrals +SET "status" = 'voided', + "voided_at" = NOW(), + "voided_reason" = $2 +WHERE "polar_subscription_id" = $1 + AND "status" IN ('pending', 'redeemable'); diff --git a/sql/email_verification_codes/delete.sql b/sql/email_verification_codes/delete.sql new file mode 100644 index 0000000..85d4aa7 --- /dev/null +++ b/sql/email_verification_codes/delete.sql @@ -0,0 +1 @@ +DELETE FROM email_verification_codes WHERE "user_id" = $1; diff --git a/sql/email_verification_codes/get_by_user_id.sql b/sql/email_verification_codes/get_by_user_id.sql new file mode 100644 index 0000000..24d564b --- /dev/null +++ b/sql/email_verification_codes/get_by_user_id.sql @@ -0,0 +1,3 @@ +SELECT "user_id", "code", "expires_at", "created_at", "failed_attempts" +FROM email_verification_codes +WHERE "user_id" = $1; diff --git a/sql/email_verification_codes/increment_attempts.sql b/sql/email_verification_codes/increment_attempts.sql new file mode 100644 index 0000000..8ad48f3 --- /dev/null +++ b/sql/email_verification_codes/increment_attempts.sql @@ -0,0 +1 @@ +UPDATE email_verification_codes SET "failed_attempts" = "failed_attempts" + 1 WHERE "user_id" = $1 RETURNING "failed_attempts"; diff --git a/sql/email_verification_codes/schema.sql b/sql/email_verification_codes/schema.sql new file mode 100644 index 0000000..097b5ae --- /dev/null +++ b/sql/email_verification_codes/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS email_verification_codes ( + "user_id" INT8 NOT NULL PRIMARY KEY, + "code" TEXT NOT NULL, + "expires_at" TIMESTAMPTZ NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "failed_attempts" INT NOT NULL DEFAULT 0 +); diff --git a/sql/email_verification_codes/upsert.sql b/sql/email_verification_codes/upsert.sql new file mode 100644 index 0000000..577ace0 --- /dev/null +++ b/sql/email_verification_codes/upsert.sql @@ -0,0 +1,4 @@ +INSERT INTO email_verification_codes ("user_id", "code", "expires_at", "failed_attempts") +VALUES ($1, $2, $3, 0) +ON CONFLICT ("user_id") +DO UPDATE SET "code" = $2, "expires_at" = $3, "created_at" = NOW(), "failed_attempts" = 0; diff --git a/sql/notification_preferences/get_by_user_id.sql b/sql/notification_preferences/get_by_user_id.sql new file mode 100644 index 0000000..17b9d71 --- /dev/null +++ b/sql/notification_preferences/get_by_user_id.sql @@ -0,0 +1,3 @@ +SELECT "user_id", "category", "discord_dm", "email", "in_app" +FROM notification_preferences +WHERE "user_id" = $1; diff --git a/sql/notification_preferences/get_by_user_id_and_category.sql b/sql/notification_preferences/get_by_user_id_and_category.sql new file mode 100644 index 0000000..19f595e --- /dev/null +++ b/sql/notification_preferences/get_by_user_id_and_category.sql @@ -0,0 +1,4 @@ +SELECT "user_id", "category", "discord_dm", "email", "in_app" +FROM notification_preferences +WHERE "user_id" = $1 + AND "category" = $2; diff --git a/sql/notification_preferences/schema.sql b/sql/notification_preferences/schema.sql new file mode 100644 index 0000000..0142b93 --- /dev/null +++ b/sql/notification_preferences/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS notification_preferences ( + "user_id" INT8 NOT NULL, + "category" TEXT NOT NULL, + "discord_dm" BOOLEAN NOT NULL DEFAULT TRUE, + "email" BOOLEAN NOT NULL DEFAULT FALSE, + "in_app" BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY ("user_id", "category") +); diff --git a/sql/notification_preferences/upsert.sql b/sql/notification_preferences/upsert.sql new file mode 100644 index 0000000..b5184f7 --- /dev/null +++ b/sql/notification_preferences/upsert.sql @@ -0,0 +1,4 @@ +INSERT INTO notification_preferences ("user_id", "category", "discord_dm", "email", "in_app") +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT ("user_id", "category") DO UPDATE +SET "discord_dm" = $3, "email" = $4, "in_app" = $5; diff --git a/sql/notifications/count_by_user_id.sql b/sql/notifications/count_by_user_id.sql new file mode 100644 index 0000000..f475104 --- /dev/null +++ b/sql/notifications/count_by_user_id.sql @@ -0,0 +1,4 @@ +SELECT COUNT(*) +FROM notifications +WHERE "user_id" = $1 + AND ($2::TEXT IS NULL OR "category" = $2); diff --git a/sql/notifications/count_unread_by_user_id.sql b/sql/notifications/count_unread_by_user_id.sql new file mode 100644 index 0000000..c528b50 --- /dev/null +++ b/sql/notifications/count_unread_by_user_id.sql @@ -0,0 +1,4 @@ +SELECT COUNT(*) +FROM notifications +WHERE "user_id" = $1 + AND "read" = FALSE; diff --git a/sql/notifications/create.sql b/sql/notifications/create.sql new file mode 100644 index 0000000..7ad1e5d --- /dev/null +++ b/sql/notifications/create.sql @@ -0,0 +1,3 @@ +INSERT INTO notifications ("user_id", "category", "title", "body", "link") +VALUES ($1, $2, $3, $4, $5) +RETURNING "id", "created_at"; diff --git a/sql/notifications/list_by_user_id.sql b/sql/notifications/list_by_user_id.sql new file mode 100644 index 0000000..9aded7e --- /dev/null +++ b/sql/notifications/list_by_user_id.sql @@ -0,0 +1,6 @@ +SELECT "id", "user_id", "category", "title", "body", "link", "read", "created_at" +FROM notifications +WHERE "user_id" = $1 + AND ($2::TEXT IS NULL OR "category" = $2) +ORDER BY "created_at" DESC +LIMIT $3 OFFSET $4; diff --git a/sql/notifications/mark_all_as_read.sql b/sql/notifications/mark_all_as_read.sql new file mode 100644 index 0000000..1385f42 --- /dev/null +++ b/sql/notifications/mark_all_as_read.sql @@ -0,0 +1,4 @@ +UPDATE notifications +SET "read" = TRUE +WHERE "user_id" = $1 + AND "read" = FALSE; diff --git a/sql/notifications/mark_as_read.sql b/sql/notifications/mark_as_read.sql new file mode 100644 index 0000000..f5e42c1 --- /dev/null +++ b/sql/notifications/mark_as_read.sql @@ -0,0 +1,4 @@ +UPDATE notifications +SET "read" = TRUE +WHERE "id" = $1 + AND "user_id" = $2; diff --git a/sql/notifications/schema.sql b/sql/notifications/schema.sql new file mode 100644 index 0000000..fd316ab --- /dev/null +++ b/sql/notifications/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS notifications ( + "id" BIGSERIAL PRIMARY KEY, + "user_id" INT8 NOT NULL, + "category" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "link" TEXT, + "read" BOOLEAN NOT NULL DEFAULT FALSE, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id_read ON notifications("user_id", "read"); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id_created ON notifications("user_id", "created_at" DESC); diff --git a/sql/user_emails/delete.sql b/sql/user_emails/delete.sql new file mode 100644 index 0000000..5ef2135 --- /dev/null +++ b/sql/user_emails/delete.sql @@ -0,0 +1 @@ +DELETE FROM user_emails WHERE "user_id" = $1; diff --git a/sql/user_emails/get_by_user_id.sql b/sql/user_emails/get_by_user_id.sql new file mode 100644 index 0000000..bd4be01 --- /dev/null +++ b/sql/user_emails/get_by_user_id.sql @@ -0,0 +1,3 @@ +SELECT "user_id", "email", "verified", "created_at", "updated_at" +FROM user_emails +WHERE "user_id" = $1; diff --git a/sql/user_emails/schema.sql b/sql/user_emails/schema.sql new file mode 100644 index 0000000..8d42431 --- /dev/null +++ b/sql/user_emails/schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS user_emails ( + "user_id" INT8 NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "verified" BOOLEAN NOT NULL DEFAULT FALSE, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/sql/user_emails/set_verified.sql b/sql/user_emails/set_verified.sql new file mode 100644 index 0000000..a48aca0 --- /dev/null +++ b/sql/user_emails/set_verified.sql @@ -0,0 +1,3 @@ +UPDATE user_emails +SET "verified" = $2, "updated_at" = NOW() +WHERE "user_id" = $1; diff --git a/sql/user_emails/upsert.sql b/sql/user_emails/upsert.sql new file mode 100644 index 0000000..56c0c38 --- /dev/null +++ b/sql/user_emails/upsert.sql @@ -0,0 +1,4 @@ +INSERT INTO user_emails ("user_id", "email") +VALUES ($1, $2) +ON CONFLICT ("user_id") +DO UPDATE SET "email" = $2, "verified" = FALSE, "updated_at" = NOW(); diff --git a/useremails.go b/useremails.go new file mode 100644 index 0000000..0165248 --- /dev/null +++ b/useremails.go @@ -0,0 +1,76 @@ +package database + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type UserEmails struct { + *pgxpool.Pool +} + +type UserEmail struct { + UserId uint64 `json:"user_id"` + Email string `json:"email"` + Verified bool `json:"verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +var ( + //go:embed sql/user_emails/schema.sql + userEmailsSchema string + + //go:embed sql/user_emails/upsert.sql + userEmailsUpsert string + + //go:embed sql/user_emails/get_by_user_id.sql + userEmailsGetByUserId string + + //go:embed sql/user_emails/set_verified.sql + userEmailsSetVerified string + + //go:embed sql/user_emails/delete.sql + userEmailsDelete string +) + +func newUserEmails(db *pgxpool.Pool) *UserEmails { + return &UserEmails{db} +} + +func (UserEmails) Schema() string { + return userEmailsSchema +} + +func (t *UserEmails) Upsert(ctx context.Context, userId uint64, email string) error { + _, err := t.Exec(ctx, userEmailsUpsert, userId, email) + return err +} + +func (t *UserEmails) GetByUserId(ctx context.Context, userId uint64) (*UserEmail, error) { + var ue UserEmail + if err := t.QueryRow(ctx, userEmailsGetByUserId, userId).Scan( + &ue.UserId, &ue.Email, &ue.Verified, &ue.CreatedAt, &ue.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &ue, nil +} + +func (t *UserEmails) SetVerified(ctx context.Context, userId uint64, verified bool) error { + _, err := t.Exec(ctx, userEmailsSetVerified, userId, verified) + return err +} + +func (t *UserEmails) Delete(ctx context.Context, userId uint64) error { + _, err := t.Exec(ctx, userEmailsDelete, userId) + return err +} From 685f7bd65807e5a1aaa9e6aecf9d0c1031df02d1 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 18 May 2026 19:44:57 +0100 Subject: [PATCH 20/49] form input apis Signed-off-by: Ben --- forminputapiconfig.go | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/forminputapiconfig.go b/forminputapiconfig.go index d8b2296..7c09fcc 100644 --- a/forminputapiconfig.go +++ b/forminputapiconfig.go @@ -15,6 +15,7 @@ type FormInputApiConfig struct { EndpointUrl string `json:"endpoint_url"` Method string `json:"method"` CacheDurationSeconds *int `json:"cache_duration_seconds,omitempty"` + NoOptionsMessage *string `json:"no_options_message,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -37,6 +38,7 @@ func (f FormInputApiConfigTable) Schema() string { "endpoint_url" VARCHAR(500) NOT NULL, "method" VARCHAR(10) NOT NULL DEFAULT 'GET', "cache_duration_seconds" INT DEFAULT 300, + "no_options_message" VARCHAR(100), "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY("form_input_id") REFERENCES form_input("id") ON DELETE CASCADE, @@ -50,7 +52,7 @@ func (f FormInputApiConfigTable) Schema() string { func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (config FormInputApiConfig, ok bool, e error) { query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "created_at", "updated_at" + SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "created_at", "updated_at" FROM form_input_api_config WHERE "form_input_id" = $1;` @@ -60,6 +62,7 @@ func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (con &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, + &config.NoOptionsMessage, &config.CreatedAt, &config.UpdatedAt, ) @@ -77,7 +80,7 @@ func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (con func (f *FormInputApiConfigTable) GetById(ctx context.Context, id int) (config FormInputApiConfig, ok bool, e error) { query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "created_at", "updated_at" + SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "created_at", "updated_at" FROM form_input_api_config WHERE "id" = $1;` @@ -87,6 +90,7 @@ func (f *FormInputApiConfigTable) GetById(ctx context.Context, id int) (config F &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, + &config.NoOptionsMessage, &config.CreatedAt, &config.UpdatedAt, ) @@ -138,7 +142,7 @@ func (f *FormInputApiConfigTable) GetByFormId(ctx context.Context, formId int) ( func (f *FormInputApiConfigTable) GetByFormInputId(ctx context.Context, formInputId int) (config FormInputApiConfig, ok bool, e error) { query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "created_at", "updated_at" + SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "created_at", "updated_at" FROM form_input_api_config WHERE "form_input_id" = $1;` @@ -148,6 +152,7 @@ func (f *FormInputApiConfigTable) GetByFormInputId(ctx context.Context, formInpu &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, + &config.NoOptionsMessage, &config.CreatedAt, &config.UpdatedAt, ) @@ -198,57 +203,59 @@ func (f *FormInputApiConfigTable) GetAllByGuild(ctx context.Context, guildId uin return configs, rows.Err() } -func (f *FormInputApiConfigTable) Create(ctx context.Context, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int) (int, error) { +func (f *FormInputApiConfigTable) Create(ctx context.Context, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string) (int, error) { query := ` - INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds") - VALUES($1, $2, $3, $4) + INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message") + VALUES($1, $2, $3, $4, $5) RETURNING "id";` var id int - if err := f.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds).Scan(&id); err != nil { + if err := f.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds, noOptionsMessage).Scan(&id); err != nil { return 0, err } return id, nil } -func (f *FormInputApiConfigTable) CreateTx(ctx context.Context, tx pgx.Tx, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int) (int, error) { +func (f *FormInputApiConfigTable) CreateTx(ctx context.Context, tx pgx.Tx, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string) (int, error) { query := ` - INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds") - VALUES($1, $2, $3, $4) + INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message") + VALUES($1, $2, $3, $4, $5) RETURNING "id";` var id int - if err := tx.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds).Scan(&id); err != nil { + if err := tx.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds, noOptionsMessage).Scan(&id); err != nil { return 0, err } return id, nil } -func (f *FormInputApiConfigTable) Update(ctx context.Context, id int, endpointUrl string, method string, cacheDurationSeconds *int) error { +func (f *FormInputApiConfigTable) Update(ctx context.Context, id int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string) error { query := ` UPDATE form_input_api_config SET "endpoint_url" = $2, "method" = $3, "cache_duration_seconds" = $4, + "no_options_message" = $5, "updated_at" = CURRENT_TIMESTAMP WHERE "id" = $1;` - _, err := f.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds) + _, err := f.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds, noOptionsMessage) return err } -func (f *FormInputApiConfigTable) UpdateTx(ctx context.Context, tx pgx.Tx, id int, endpointUrl string, method string, cacheDurationSeconds *int) error { +func (f *FormInputApiConfigTable) UpdateTx(ctx context.Context, tx pgx.Tx, id int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string) error { query := ` UPDATE form_input_api_config SET "endpoint_url" = $2, "method" = $3, "cache_duration_seconds" = $4, + "no_options_message" = $5, "updated_at" = CURRENT_TIMESTAMP WHERE "id" = $1;` - _, err := tx.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds) + _, err := tx.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds, noOptionsMessage) return err } From 1ec684ceee9a8efdc8b3acab225de0ee1770e2a7 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 19 May 2026 20:00:56 +0100 Subject: [PATCH 21/49] analytics Signed-off-by: Ben --- adminanalytics.go | 233 +++++++++++++++++++++++++++++++++++++++++ analyticstypes.go | 16 +++ closereason.go | 38 +++++++ database.go | 5 + firstresponsetime.go | 63 +++++++++++ ticketmessagecounts.go | 156 +++++++++++++++++++++++++++ tickets.go | 159 +++++++++++++++++++++++++++- ticketsource.go | 20 ++++ 8 files changed, 685 insertions(+), 5 deletions(-) create mode 100644 adminanalytics.go create mode 100644 ticketmessagecounts.go create mode 100644 ticketsource.go diff --git a/adminanalytics.go b/adminanalytics.go new file mode 100644 index 0000000..eb329e9 --- /dev/null +++ b/adminanalytics.go @@ -0,0 +1,233 @@ +package database + +import ( + "context" + "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 := json.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 +} diff --git a/analyticstypes.go b/analyticstypes.go index df5375b..ab078fa 100644 --- a/analyticstypes.go +++ b/analyticstypes.go @@ -41,3 +41,19 @@ type AutoCloseStats struct { AutoClosed int `json:"auto_closed"` ManualClosed int `json:"manual_closed"` } + +type PeakHourEntry struct { + DayOfWeek int `json:"day_of_week"` + HourOfDay int `json:"hour_of_day"` + Count int `json:"count"` +} + +type SourceBreakdown struct { + Source TicketSource `json:"source"` + Count int `json:"count"` +} + +type ResponseTimeByHour struct { + HourOfDay int `json:"hour_of_day"` + AvgResponseTime *time.Duration `json:"avg_response_time"` +} diff --git a/closereason.go b/closereason.go index 2cdf1a6..a7e7bff 100644 --- a/closereason.go +++ b/closereason.go @@ -151,6 +151,44 @@ LIMIT $3;` return reasons, nil } +type CloseReasonCount struct { + Reason string `json:"reason"` + Count int `json:"count"` +} + +func (c *CloseMetadataTable) GetTopCloseReasonsWithCount(ctx context.Context, guildId uint64, panelId *int, limit, days int) ([]CloseReasonCount, error) { + query := ` +SELECT cr.close_reason, COUNT(*)::int AS count +FROM close_reason cr +INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id +WHERE cr.guild_id = $1 + AND ($2::int IS NULL OR t.panel_id = $2) + AND cr.close_reason IS NOT NULL + AND cr.close_reason != '' + AND cr.close_reason != 'Automatically closed due to inactivity' + AND ($4 = 0 OR t.open_time > NOW() - make_interval(days => $4)) +GROUP BY cr.close_reason +ORDER BY COUNT(*) DESC +LIMIT $3;` + + rows, err := c.Query(ctx, query, guildId, panelId, limit, days) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []CloseReasonCount + for rows.Next() { + var r CloseReasonCount + if err := rows.Scan(&r.Reason, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + func (c *CloseMetadataTable) GetAutoCloseVsManualClose(ctx context.Context, guildId uint64, nDays int) (AutoCloseStats, error) { query := ` SELECT diff --git a/database.go b/database.go index 1effb05..83cfc10 100644 --- a/database.go +++ b/database.go @@ -110,6 +110,8 @@ type Database struct { Webhooks *WebhookTable TicketLabels *TicketLabelsTable TicketLabelAssignments *TicketLabelAssignmentsTable + TicketMessageCounts *TicketMessageCounts + AdminAnalytics *AdminAnalyticsTable Whitelabel *WhitelabelBotTable WhitelabelErrors *WhitelabelErrors WhitelabelGuilds *WhitelabelGuilds @@ -218,6 +220,8 @@ func NewDatabase(pool *pgxpool.Pool) *Database { Webhooks: newWebhookTable(pool), TicketLabels: newTicketLabelsTable(pool), TicketLabelAssignments: newTicketLabelAssignmentsTable(pool), + TicketMessageCounts: newTicketMessageCounts(pool), + AdminAnalytics: newAdminAnalyticsTable(pool), Whitelabel: newWhitelabelBotTable(pool), WhitelabelErrors: newWhitelabelErrors(pool), WhitelabelGuilds: newWhitelabelGuilds(pool), @@ -337,6 +341,7 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.CategoryUpdateQueue, // Must be created after Tickets table d.TicketLabels, // Must be created after Tickets table d.TicketLabelAssignments, // Must be created after Tickets and TicketLabels tables + d.TicketMessageCounts, // Must be created after Tickets table d.GalleryListings, // Gallery panel listings d.GalleryListingTags, // Must be created after GalleryListings table d.FirstResponseTime, diff --git a/firstresponsetime.go b/firstresponsetime.go index da8a5dd..64e1fdf 100644 --- a/firstresponsetime.go +++ b/firstresponsetime.go @@ -122,6 +122,69 @@ WHERE frt.guild_id = $1;` }, nil } +func (f *FirstResponseTime) GetAverageByHour(ctx context.Context, guildId uint64, days int) ([]ResponseTimeByHour, error) { + if days == 0 { + query := ` +SELECT + EXTRACT(HOUR FROM t.open_time)::int AS hour_of_day, + AVG(frt.response_time) AS avg_response_time +FROM first_response_time frt +INNER JOIN tickets t ON frt.guild_id = t.guild_id AND frt.ticket_id = t.id +WHERE frt.guild_id = $1 +GROUP BY hour_of_day +ORDER BY hour_of_day;` + + rows, err := f.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []ResponseTimeByHour + for rows.Next() { + var r ResponseTimeByHour + if err := rows.Scan(&r.HourOfDay, &r.AvgResponseTime); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil + } + + parsedInterval := pgtype.Interval{} + if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { + return nil, err + } + + query := ` +SELECT + EXTRACT(HOUR FROM t.open_time)::int AS hour_of_day, + AVG(frt.response_time) AS avg_response_time +FROM first_response_time frt +INNER JOIN tickets t ON frt.guild_id = t.guild_id AND frt.ticket_id = t.id +WHERE frt.guild_id = $1 AND t.open_time > NOW() - $2::interval +GROUP BY hour_of_day +ORDER BY hour_of_day;` + + rows, err := f.Query(ctx, query, guildId, parsedInterval) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []ResponseTimeByHour + for rows.Next() { + var r ResponseTimeByHour + if err := rows.Scan(&r.HourOfDay, &r.AvgResponseTime); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + func (f *FirstResponseTime) Set(ctx context.Context, guildId, userId uint64, ticketId int, responseTime time.Duration) (err error) { query := `INSERT INTO first_response_time("guild_id", "ticket_id", "user_id", "response_time") VALUES($1, $2, $3, $4) ON CONFLICT("guild_id", "ticket_id") DO NOTHING;` _, err = f.Exec(ctx, query, guildId, ticketId, userId, responseTime) diff --git a/ticketmessagecounts.go b/ticketmessagecounts.go new file mode 100644 index 0000000..19fc103 --- /dev/null +++ b/ticketmessagecounts.go @@ -0,0 +1,156 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +type TicketMessageCounts struct { + *pgxpool.Pool +} + +func newTicketMessageCounts(db *pgxpool.Pool) *TicketMessageCounts { + return &TicketMessageCounts{db} +} + +func (t TicketMessageCounts) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS ticket_message_counts( + "guild_id" int8 NOT NULL, + "ticket_id" int4 NOT NULL, + "staff_messages" int4 NOT NULL DEFAULT 0, + "user_messages" int4 NOT NULL DEFAULT 0, + FOREIGN KEY("guild_id", "ticket_id") REFERENCES tickets("guild_id", "id"), + PRIMARY KEY("guild_id", "ticket_id") +); +CREATE INDEX IF NOT EXISTS ticket_message_counts_guild_id ON ticket_message_counts("guild_id"); +` +} + +type MessageCounts struct { + GuildId uint64 `json:"guild_id"` + TicketId int `json:"ticket_id"` + StaffMessages int `json:"staff_messages"` + UserMessages int `json:"user_messages"` +} + +func (t *TicketMessageCounts) Get(ctx context.Context, guildId uint64, ticketId int) (counts MessageCounts, e error) { + query := `SELECT "guild_id", "ticket_id", "staff_messages", "user_messages" FROM ticket_message_counts WHERE "guild_id" = $1 AND "ticket_id" = $2;` + if err := t.QueryRow(ctx, query, guildId, ticketId).Scan(&counts.GuildId, &counts.TicketId, &counts.StaffMessages, &counts.UserMessages); err != nil && err != pgx.ErrNoRows { + e = err + } + + return +} + +func (t *TicketMessageCounts) IncrementStaffMessages(ctx context.Context, guildId uint64, ticketId int) (err error) { + query := ` +INSERT INTO ticket_message_counts("guild_id", "ticket_id", "staff_messages", "user_messages") +VALUES($1, $2, 1, 0) +ON CONFLICT("guild_id", "ticket_id") DO UPDATE SET "staff_messages" = ticket_message_counts."staff_messages" + 1;` + _, err = t.Exec(ctx, query, guildId, ticketId) + return +} + +func (t *TicketMessageCounts) IncrementUserMessages(ctx context.Context, guildId uint64, ticketId int) (err error) { + query := ` +INSERT INTO ticket_message_counts("guild_id", "ticket_id", "staff_messages", "user_messages") +VALUES($1, $2, 0, 1) +ON CONFLICT("guild_id", "ticket_id") DO UPDATE SET "user_messages" = ticket_message_counts."user_messages" + 1;` + _, err = t.Exec(ctx, query, guildId, ticketId) + return +} + +func (t *TicketMessageCounts) Set(ctx context.Context, guildId uint64, ticketId int, staffMessages, userMessages int) (err error) { + query := ` +INSERT INTO ticket_message_counts("guild_id", "ticket_id", "staff_messages", "user_messages") +VALUES($1, $2, $3, $4) +ON CONFLICT("guild_id", "ticket_id") DO UPDATE SET "staff_messages" = $3, "user_messages" = $4;` + _, err = t.Exec(ctx, query, guildId, ticketId, staffMessages, userMessages) + return +} + +type AverageMessageCounts struct { + AvgStaffMessages *float64 `json:"avg_staff_messages"` + AvgUserMessages *float64 `json:"avg_user_messages"` + AvgTotalMessages *float64 `json:"avg_total_messages"` +} + +func (t *TicketMessageCounts) GetAverageMessageCounts(ctx context.Context, guildId uint64, days int) (avg AverageMessageCounts, e error) { + if days == 0 { + query := ` +SELECT + AVG(tmc."staff_messages"), + AVG(tmc."user_messages"), + AVG(tmc."staff_messages" + tmc."user_messages") +FROM ticket_message_counts tmc +INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" +WHERE tmc."guild_id" = $1 AND t."open" = false;` + if err := t.QueryRow(ctx, query, guildId).Scan(&avg.AvgStaffMessages, &avg.AvgUserMessages, &avg.AvgTotalMessages); err != nil && err != pgx.ErrNoRows { + e = err + } + return + } + + parsedInterval := pgtype.Interval{} + if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { + return avg, err + } + + query := ` +SELECT + AVG(tmc."staff_messages"), + AVG(tmc."user_messages"), + AVG(tmc."staff_messages" + tmc."user_messages") +FROM ticket_message_counts tmc +INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" +WHERE tmc."guild_id" = $1 AND t."open" = false AND t."open_time" > NOW() - $2::interval;` + if err := t.QueryRow(ctx, query, guildId, parsedInterval).Scan(&avg.AvgStaffMessages, &avg.AvgUserMessages, &avg.AvgTotalMessages); err != nil && err != pgx.ErrNoRows { + e = err + } + + return +} + +type OneTouchResolution struct { + OneTouchCount int `json:"one_touch_count"` + TotalClosed int `json:"total_closed"` +} + +func (t *TicketMessageCounts) GetOneTouchResolutionRate(ctx context.Context, guildId uint64, days int) (result OneTouchResolution, e error) { + if days == 0 { + query := ` +SELECT + COUNT(*) FILTER (WHERE tmc."staff_messages" = 1), + COUNT(*) +FROM ticket_message_counts tmc +INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" +WHERE tmc."guild_id" = $1 AND t."open" = false;` + if err := t.QueryRow(ctx, query, guildId).Scan(&result.OneTouchCount, &result.TotalClosed); err != nil && err != pgx.ErrNoRows { + e = err + } + return + } + + parsedInterval := pgtype.Interval{} + if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { + return result, err + } + + query := ` +SELECT + COUNT(*) FILTER (WHERE tmc."staff_messages" = 1), + COUNT(*) +FROM ticket_message_counts tmc +INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" +WHERE tmc."guild_id" = $1 AND t."open" = false AND t."open_time" > NOW() - $2::interval;` + if err := t.QueryRow(ctx, query, guildId, parsedInterval).Scan(&result.OneTouchCount, &result.TotalClosed); err != nil && err != pgx.ErrNoRows { + e = err + } + + return +} diff --git a/tickets.go b/tickets.go index 5d3499d..483c228 100644 --- a/tickets.go +++ b/tickets.go @@ -28,6 +28,7 @@ type Ticket struct { JoinMessageId *uint64 `json:"join_message_id"` NotesThreadId *uint64 `json:"notes_thread_id"` Status model.TicketStatus `json:"status"` + Source TicketSource `json:"source"` } type TicketQueryOptions struct { @@ -95,6 +96,7 @@ CREATE TABLE IF NOT EXISTS tickets( "join_message_id" int8 DEFAULT NULL, "notes_thread_id" int8 DEFAULT NULL, "status" ticket_status NOT NULL, + "source" int2 NOT NULL DEFAULT 0, FOREIGN KEY("panel_id") REFERENCES panels("panel_id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY("id", "guild_id") ); @@ -105,10 +107,11 @@ CREATE TABLE guild_ticket_counters ( CREATE INDEX IF NOT EXISTS tickets_channel_id ON tickets("channel_id"); CREATE INDEX IF NOT EXISTS tickets_panel_id ON tickets("panel_id"); CREATE INDEX IF NOT EXISTS tickets_guild_id_open_time ON tickets("guild_id", "open_time"); +ALTER TABLE tickets ADD COLUMN IF NOT EXISTS "source" int2 NOT NULL DEFAULT 0; ` } -func (t *TicketTable) Create(ctx context.Context, guildId, userId uint64, isThread bool, panelId *int) (id int, err error) { +func (t *TicketTable) Create(ctx context.Context, guildId, userId uint64, isThread bool, panelId *int, source TicketSource) (id int, err error) { tx, err := t.Begin(ctx) if err != nil { return 0, err @@ -120,20 +123,20 @@ func (t *TicketTable) Create(ctx context.Context, guildId, userId uint64, isThre if err := tx.QueryRow(ctx, ` INSERT INTO guild_ticket_counters (guild_id, last_ticket_id) VALUES ($1, 1) - ON CONFLICT (guild_id) DO UPDATE + ON CONFLICT (guild_id) DO UPDATE SET last_ticket_id = guild_ticket_counters.last_ticket_id + 1 RETURNING last_ticket_id`, guildId).Scan(&ticketId); err != nil { return 0, err } query := ` -INSERT INTO tickets("id", "guild_id", "user_id", "open", "open_time", "is_thread", "panel_id", "status") +INSERT INTO tickets("id", "guild_id", "user_id", "open", "open_time", "is_thread", "panel_id", "status", "source") VALUES( - $1, $2, $3, true, NOW(), $4, $5, $6 + $1, $2, $3, true, NOW(), $4, $5, $6, $7 ) RETURNING "id";` - if err := tx.QueryRow(ctx, query, ticketId, guildId, userId, isThread, panelId, model.TicketStatusOpen).Scan(&id); err != nil { + if err := tx.QueryRow(ctx, query, ticketId, guildId, userId, isThread, panelId, model.TicketStatusOpen, source).Scan(&id); err != nil { return 0, err } @@ -1385,6 +1388,32 @@ ORDER BY d::date DESC;` return counts, nil } +func (t *TicketTable) GetTicketsPerWeek(ctx context.Context, guildId uint64, nDays int) ([]CountOnDate, error) { + query := ` +SELECT date_trunc('week', open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*)::bigint AS count +FROM tickets +WHERE guild_id = $1 AND ($2 = 0 OR open_time > CURRENT_DATE - $2 * INTERVAL '1 day') +GROUP BY 1 +ORDER BY 1;` + + rows, err := t.Query(ctx, query, guildId, nDays) + if err != nil { + return nil, err + } + defer rows.Close() + + var counts []CountOnDate + for rows.Next() { + var c CountOnDate + if err := rows.Scan(&c.Date, &c.Count); err != nil { + return nil, err + } + counts = append(counts, c) + } + + return counts, nil +} + func (t *TicketTable) GetTicketDurationTripleWindow(ctx context.Context, guildId uint64) (TripleWindow, error) { query := ` SELECT @@ -1457,6 +1486,126 @@ WHERE guild_id = $1 AND open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day';` return split, nil } +func (t *TicketTable) GetPeakHours(ctx context.Context, guildId uint64, days int) ([]PeakHourEntry, error) { + if days == 0 { + query := ` +SELECT + EXTRACT(DOW FROM open_time)::int AS day_of_week, + EXTRACT(HOUR FROM open_time)::int AS hour_of_day, + COUNT(*)::int AS count +FROM tickets +WHERE guild_id = $1 +GROUP BY day_of_week, hour_of_day +ORDER BY day_of_week, hour_of_day;` + + rows, err := t.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []PeakHourEntry + for rows.Next() { + var r PeakHourEntry + if err := rows.Scan(&r.DayOfWeek, &r.HourOfDay, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil + } + + parsedInterval := pgtype.Interval{} + if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { + return nil, err + } + + query := ` +SELECT + EXTRACT(DOW FROM open_time)::int AS day_of_week, + EXTRACT(HOUR FROM open_time)::int AS hour_of_day, + COUNT(*)::int AS count +FROM tickets +WHERE guild_id = $1 AND open_time > NOW() - $2::interval +GROUP BY day_of_week, hour_of_day +ORDER BY day_of_week, hour_of_day;` + + rows, err := t.Query(ctx, query, guildId, parsedInterval) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []PeakHourEntry + for rows.Next() { + var r PeakHourEntry + if err := rows.Scan(&r.DayOfWeek, &r.HourOfDay, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + +func (t *TicketTable) GetTicketsBySource(ctx context.Context, guildId uint64, days int) ([]SourceBreakdown, error) { + if days == 0 { + query := ` +SELECT "source", COUNT(*)::int AS count +FROM tickets +WHERE guild_id = $1 +GROUP BY "source" +ORDER BY count DESC;` + + rows, err := t.Query(ctx, query, guildId) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []SourceBreakdown + for rows.Next() { + var r SourceBreakdown + if err := rows.Scan(&r.Source, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil + } + + parsedInterval := pgtype.Interval{} + if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { + return nil, err + } + + query := ` +SELECT "source", COUNT(*)::int AS count +FROM tickets +WHERE guild_id = $1 AND open_time > NOW() - $2::interval +GROUP BY "source" +ORDER BY count DESC;` + + rows, err := t.Query(ctx, query, guildId, parsedInterval) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []SourceBreakdown + for rows.Next() { + var r SourceBreakdown + if err := rows.Scan(&r.Source, &r.Count); err != nil { + return nil, err + } + results = append(results, r) + } + + return results, nil +} + func (t *TicketTable) GetBacklogTrend(ctx context.Context, guildId uint64, nDays int) ([]CountOnDate, error) { query := ` WITH opens AS ( diff --git a/ticketsource.go b/ticketsource.go new file mode 100644 index 0000000..bafb431 --- /dev/null +++ b/ticketsource.go @@ -0,0 +1,20 @@ +package database + +type TicketSource int16 + +const ( + TicketSourceUnknown TicketSource = 0 + TicketSourcePanel TicketSource = 1 + TicketSourceCommand TicketSource = 2 +) + +func (s TicketSource) String() string { + switch s { + case TicketSourcePanel: + return "panel" + case TicketSourceCommand: + return "command" + default: + return "unknown" + } +} From 7a12766f489795f7a61fa1e46db8bd1d5944bfdd Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 24 May 2026 09:35:31 +0100 Subject: [PATCH 22/49] feat: edit affiliate codes Signed-off-by: Ben --- affiliatecodes.go | 8 ++++++++ auditlog.go | 1 + sql/affiliate_codes/update_code.sql | 1 + tickets.go | 8 ++++---- 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 sql/affiliate_codes/update_code.sql diff --git a/affiliatecodes.go b/affiliatecodes.go index 78b7724..dd9469d 100644 --- a/affiliatecodes.go +++ b/affiliatecodes.go @@ -62,6 +62,9 @@ var ( //go:embed sql/affiliate_codes/update_rates.sql affiliateCodesUpdateRates string + + //go:embed sql/affiliate_codes/update_code.sql + affiliateCodesUpdateCode string ) func newAffiliateCodes(db *pgxpool.Pool) *AffiliateCodes { @@ -165,3 +168,8 @@ func (t *AffiliateCodes) UpdateRates(ctx context.Context, id uuid.UUID, discount _, err := t.Exec(ctx, affiliateCodesUpdateRates, id, discountBasisPoints, creditPercentage) return err } + +func (t *AffiliateCodes) UpdateCode(ctx context.Context, id uuid.UUID, code string) error { + _, err := t.Exec(ctx, affiliateCodesUpdateCode, id, code) + return err +} diff --git a/auditlog.go b/auditlog.go index 4561723..2de25e0 100644 --- a/auditlog.go +++ b/auditlog.go @@ -135,6 +135,7 @@ const ( AuditActionAffiliateRedeem AuditActionType = 424 AuditActionAffiliateVoid AuditActionType = 425 AuditActionAffiliateUpdateRate AuditActionType = 426 + AuditActionAffiliateUpdateCode AuditActionType = 427 AuditActionNotificationPreferencesUpdate AuditActionType = 430 AuditActionUserEmailUpdate AuditActionType = 431 diff --git a/sql/affiliate_codes/update_code.sql b/sql/affiliate_codes/update_code.sql new file mode 100644 index 0000000..4713eb2 --- /dev/null +++ b/sql/affiliate_codes/update_code.sql @@ -0,0 +1 @@ +UPDATE affiliate_codes SET code = $2 WHERE id = $1; diff --git a/tickets.go b/tickets.go index 483c228..7553506 100644 --- a/tickets.go +++ b/tickets.go @@ -1490,8 +1490,8 @@ func (t *TicketTable) GetPeakHours(ctx context.Context, guildId uint64, days int if days == 0 { query := ` SELECT - EXTRACT(DOW FROM open_time)::int AS day_of_week, - EXTRACT(HOUR FROM open_time)::int AS hour_of_day, + EXTRACT(DOW FROM open_time AT TIME ZONE 'UTC')::int AS day_of_week, + EXTRACT(HOUR FROM open_time AT TIME ZONE 'UTC')::int AS hour_of_day, COUNT(*)::int AS count FROM tickets WHERE guild_id = $1 @@ -1523,8 +1523,8 @@ ORDER BY day_of_week, hour_of_day;` query := ` SELECT - EXTRACT(DOW FROM open_time)::int AS day_of_week, - EXTRACT(HOUR FROM open_time)::int AS hour_of_day, + EXTRACT(DOW FROM open_time AT TIME ZONE 'UTC')::int AS day_of_week, + EXTRACT(HOUR FROM open_time AT TIME ZONE 'UTC')::int AS hour_of_day, COUNT(*)::int AS count FROM tickets WHERE guild_id = $1 AND open_time > NOW() - $2::interval From 51bc7cc9c10c3d8e46da2904ae3a9a94195d1f43 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Wed, 27 May 2026 00:52:42 +0200 Subject: [PATCH 23/49] Include emoji_animated in panel SELECT Add panels.emoji_animated to the SELECT list in multipanel targets so the query returns the emoji animation flag for panels. This allows the application to correctly handle animated emojis when loading panel records. --- multipaneltargets.go | 1 + 1 file changed, 1 insertion(+) diff --git a/multipaneltargets.go b/multipaneltargets.go index b4d9766..572ae44 100644 --- a/multipaneltargets.go +++ b/multipaneltargets.go @@ -56,6 +56,7 @@ SELECT panels.target_category, panels.emoji_name, panels.emoji_id, + panels.emoji_animated, panels.welcome_message, panels.default_team, panels.custom_id, From 103fd220caf98adaeb84df6f2a3001d243094f06 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Thu, 28 May 2026 23:08:39 +0200 Subject: [PATCH 24/49] DB schema safety, context propagation, and resource fixes Make SQL enum creation idempotent and avoid duplicate_object errors by wrapping ticket_status creation in a DO $$ block in category_update_queue schema and tickets schema; also make guild_ticket_counters CREATE TABLE IF NOT EXISTS. Propagate context in cmd/viewrefresher: create a context, pass it to pgxpool.Connect and doRefresh, and update doRefresh/view.Refresh to accept ctx. Close rows in CategoryUpdateQueue.GetReadyForUpdate to prevent leaks. Alias encoding/json to stdjson in adminanalytics and use stdjson.Unmarshal to avoid naming conflicts. Minor formatting and ordering tweaks in Database initialization and CreateTables (including adding AdminAnalytics to the creation list). --- adminanalytics.go | 3 +- categoryupdatequeue.go | 1 + cmd/viewrefresher/main.go | 11 +++-- database.go | 69 ++++++++++++++-------------- sql/category_update_queue/schema.sql | 7 ++- tickets.go | 9 +++- 6 files changed, 59 insertions(+), 41 deletions(-) diff --git a/adminanalytics.go b/adminanalytics.go index eb329e9..170dfe9 100644 --- a/adminanalytics.go +++ b/adminanalytics.go @@ -2,6 +2,7 @@ package database import ( "context" + stdjson "encoding/json" "time" "github.com/jackc/pgx/v4" @@ -196,7 +197,7 @@ func (a *AdminAnalyticsTable) GetRetentionMetrics(ctx context.Context) (metrics } if len(churnedJson) > 0 { - if err := json.Unmarshal(churnedJson, &metrics.RecentlyChurned); err != nil { + if err := stdjson.Unmarshal(churnedJson, &metrics.RecentlyChurned); err != nil { return metrics, err } } diff --git a/categoryupdatequeue.go b/categoryupdatequeue.go index 32a2042..dc99f41 100644 --- a/categoryupdatequeue.go +++ b/categoryupdatequeue.go @@ -52,6 +52,7 @@ func (q *CategoryUpdateQueue) GetReadyForUpdate(ctx context.Context, delayInterv if err != nil { return nil, err } + defer rows.Close() var items []CategoryUpdateQueueItem for rows.Next() { diff --git a/cmd/viewrefresher/main.go b/cmd/viewrefresher/main.go index 1520fd9..922ec2d 100644 --- a/cmd/viewrefresher/main.go +++ b/cmd/viewrefresher/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "os" "time" @@ -10,6 +11,8 @@ import ( ) func main() { + ctx := context.Background() + logrus.Info("Connecting to database...") pool := must(pgxpool.Connect(ctx, os.Getenv("DATABASE_URI"))) db := database.NewDatabase(pool) @@ -17,19 +20,19 @@ func main() { if os.Getenv("DAEMON") == "true" { for { - doRefresh(db) + doRefresh(ctx, db) time.Sleep(6 * time.Hour) } } else { - doRefresh(db) + doRefresh(ctx, db) } } -func doRefresh(db *database.Database) { +func doRefresh(ctx context.Context, db *database.Database) { logrus.Info("Starting refresh...") for _, view := range db.Views() { - if err := view.Refresh(); err != nil { + if err := view.Refresh(ctx); err != nil { logrus.Errorf("Error refreshing view: %s", err.Error()) } } diff --git a/database.go b/database.go index 83cfc10..e3a60ae 100644 --- a/database.go +++ b/database.go @@ -108,10 +108,10 @@ type Database struct { VoteCredits *VoteCredits Votes *Votes Webhooks *WebhookTable - TicketLabels *TicketLabelsTable - TicketLabelAssignments *TicketLabelAssignmentsTable - TicketMessageCounts *TicketMessageCounts - AdminAnalytics *AdminAnalyticsTable + TicketLabels *TicketLabelsTable + TicketLabelAssignments *TicketLabelAssignmentsTable + TicketMessageCounts *TicketMessageCounts + AdminAnalytics *AdminAnalyticsTable Whitelabel *WhitelabelBotTable WhitelabelErrors *WhitelabelErrors WhitelabelGuilds *WhitelabelGuilds @@ -218,10 +218,10 @@ func NewDatabase(pool *pgxpool.Pool) *Database { VoteCredits: newVoteCreditsTable(pool), Votes: newVotes(pool), Webhooks: newWebhookTable(pool), - TicketLabels: newTicketLabelsTable(pool), - TicketLabelAssignments: newTicketLabelAssignmentsTable(pool), - TicketMessageCounts: newTicketMessageCounts(pool), - AdminAnalytics: newAdminAnalyticsTable(pool), + TicketLabels: newTicketLabelsTable(pool), + TicketLabelAssignments: newTicketLabelAssignmentsTable(pool), + TicketMessageCounts: newTicketMessageCounts(pool), + AdminAnalytics: newAdminAnalyticsTable(pool), Whitelabel: newWhitelabelBotTable(pool), WhitelabelErrors: newWhitelabelErrors(pool), WhitelabelGuilds: newWhitelabelGuilds(pool), @@ -296,20 +296,20 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.MultiServerSkus, d.OnCall, d.Panel, - d.PanelTicketPermissions, // must be created after panels table + d.PanelTicketPermissions, // must be created after panels table d.PanelAccessControlRules, // must be created after panels table d.MultiPanelTargets, // must be created after panels table d.PanelRoleMentions, d.PanelSupportHours, // must be created after panels table d.PanelSupportHoursSettings, // must be created after panels table - d.PanelAutoClose, // must be created after panels table + d.PanelAutoClose, // must be created after panels table d.PanelUserMention, d.PanelHereMention, d.PatreonEntitlements, - d.PolarEntitlements, // depends on entitlements - d.PolarProducts, // depends on skus - d.AffiliateCodes, // adds 'affiliate' to premium_source enum; must be after entitlements - d.AffiliateReferrals, // depends on affiliate_codes + d.PolarEntitlements, // depends on entitlements + d.PolarProducts, // depends on skus + d.AffiliateCodes, // adds 'affiliate' to premium_source enum; must be after entitlements + d.AffiliateReferrals, // depends on affiliate_codes d.Permissions, d.PremiumGuilds, d.PremiumKeys, @@ -324,26 +324,26 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.SupportTeamPermissions, // must be created after support_team table d.PanelTeams, // Must be created after panels & support teams tables d.Tag, - d.KBCategories, // Knowledge base categories - d.KBArticles, // Knowledge base articles (references categories) - d.KBSettings, // Knowledge base customisation settings - d.PanelKBCategories, // Panel-to-KB-category associations - d.Tickets, // Must be created before members table - d.TicketLastMessage, // Must be created after Tickets table - d.Participants, // Must be created after Tickets table - d.AutoCloseExclude, // Must be created after Tickets table - d.CloseReason, // Must be created after Tickets table - d.CloseRequest, // Must be created after Tickets table - d.ServiceRatings, // Must be created after Tickets table - d.ExitSurveyResponses, // Must be created after Tickets table - d.ArchiveMessages, // Must be created after Tickets table - d.ArchiveDmMessages, // Must be created after Tickets table - d.CategoryUpdateQueue, // Must be created after Tickets table - d.TicketLabels, // Must be created after Tickets table - d.TicketLabelAssignments, // Must be created after Tickets and TicketLabels tables - d.TicketMessageCounts, // Must be created after Tickets table - d.GalleryListings, // Gallery panel listings - d.GalleryListingTags, // Must be created after GalleryListings table + d.KBCategories, // Knowledge base categories + d.KBArticles, // Knowledge base articles (references categories) + d.KBSettings, // Knowledge base customisation settings + d.PanelKBCategories, // Panel-to-KB-category associations + d.Tickets, // Must be created before members table + d.TicketLastMessage, // Must be created after Tickets table + d.Participants, // Must be created after Tickets table + d.AutoCloseExclude, // Must be created after Tickets table + d.CloseReason, // Must be created after Tickets table + d.CloseRequest, // Must be created after Tickets table + d.ServiceRatings, // Must be created after Tickets table + d.ExitSurveyResponses, // Must be created after Tickets table + d.ArchiveMessages, // Must be created after Tickets table + d.ArchiveDmMessages, // Must be created after Tickets table + d.CategoryUpdateQueue, // Must be created after Tickets table + d.TicketLabels, // Must be created after Tickets table + d.TicketLabelAssignments, // Must be created after Tickets and TicketLabels tables + d.TicketMessageCounts, // Must be created after Tickets table + d.GalleryListings, // Gallery panel listings + d.GalleryListingTags, // Must be created after GalleryListings table d.FirstResponseTime, d.TicketMembers, d.TicketClaims, @@ -362,6 +362,7 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.WhitelabelStatuses, d.WhitelabelUsers, d.AuditLog, + d.AdminAnalytics, ) } diff --git a/sql/category_update_queue/schema.sql b/sql/category_update_queue/schema.sql index f7f43be..2e302e6 100644 --- a/sql/category_update_queue/schema.sql +++ b/sql/category_update_queue/schema.sql @@ -1,4 +1,9 @@ -CREATE TYPE ticket_status AS ENUM ('OPEN', 'PENDING', 'CLOSED'); +DO $$ +BEGIN + CREATE TYPE ticket_status AS ENUM ('OPEN', 'PENDING', 'CLOSED'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; CREATE TABLE IF NOT EXISTS category_update_queue ( guild_id INT8 NOT NULL, diff --git a/tickets.go b/tickets.go index 7553506..486d022 100644 --- a/tickets.go +++ b/tickets.go @@ -81,6 +81,13 @@ func newTicketTable(db *pgxpool.Pool) *TicketTable { func (t TicketTable) Schema() string { return ` +DO $$ +BEGIN + CREATE TYPE ticket_status AS ENUM ('OPEN', 'PENDING', 'CLOSED'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + CREATE TABLE IF NOT EXISTS tickets( "id" int4 NOT NULL, "guild_id" int8 NOT NULL, @@ -100,7 +107,7 @@ CREATE TABLE IF NOT EXISTS tickets( FOREIGN KEY("panel_id") REFERENCES panels("panel_id") ON DELETE SET NULL ON UPDATE CASCADE, PRIMARY KEY("id", "guild_id") ); -CREATE TABLE guild_ticket_counters ( +CREATE TABLE IF NOT EXISTS guild_ticket_counters ( guild_id bigint PRIMARY KEY, last_ticket_id integer NOT NULL DEFAULT 0 ); From 6aee1cbdd251115795c5d4982280801d00b3b73b Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 20 Jun 2026 09:44:13 +0100 Subject: [PATCH 25/49] update currency items Signed-off-by: Ben --- polarproducts.go | 12 ++++++++---- sql/polar_products/get_by_polar_product_id.sql | 2 +- sql/polar_products/insert.sql | 6 +++--- sql/polar_products/list_all.sql | 4 ++-- sql/polar_products/schema.sql | 3 ++- sql/polar_products/update.sql | 13 +++++++------ 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/polarproducts.go b/polarproducts.go index 840b7e9..a9f3e98 100644 --- a/polarproducts.go +++ b/polarproducts.go @@ -26,7 +26,8 @@ type PolarProduct struct { Name string `json:"name"` Description string `json:"description"` Interval string `json:"interval"` - PriceGbp int `json:"price_gbp"` + Price int `json:"price"` + Currency string `json:"currency"` Features []string `json:"features"` Highlighted bool `json:"highlighted"` SortOrder int `json:"sort_order"` @@ -69,7 +70,8 @@ func scanPolarProduct(row pgx.Row) (PolarProduct, error) { &p.Name, &p.Description, &p.Interval, - &p.PriceGbp, + &p.Price, + &p.Currency, &features, &p.Highlighted, &p.SortOrder, @@ -134,7 +136,8 @@ func (t *PolarProducts) Create(ctx context.Context, tx pgx.Tx, product PolarProd product.Name, product.Description, product.Interval, - product.PriceGbp, + product.Price, + product.Currency, featuresArray, product.Highlighted, product.SortOrder, @@ -152,7 +155,8 @@ func (t *PolarProducts) Update(ctx context.Context, tx pgx.Tx, product PolarProd product.Name, product.Description, product.Interval, - product.PriceGbp, + product.Price, + product.Currency, featuresArray, product.Highlighted, product.SortOrder, diff --git a/sql/polar_products/get_by_polar_product_id.sql b/sql/polar_products/get_by_polar_product_id.sql index 5b2cd5d..3fc208a 100644 --- a/sql/polar_products/get_by_polar_product_id.sql +++ b/sql/polar_products/get_by_polar_product_id.sql @@ -1,4 +1,4 @@ SELECT "id", "polar_product_id", "sku_id", "name", "description", "interval", - "price_gbp", "features", "highlighted", "sort_order", "tier", "servers_permitted" + "price", "currency", "features", "highlighted", "sort_order", "tier", "servers_permitted" FROM polar_products WHERE "polar_product_id" = $1; diff --git a/sql/polar_products/insert.sql b/sql/polar_products/insert.sql index 5bb3f30..ca99014 100644 --- a/sql/polar_products/insert.sql +++ b/sql/polar_products/insert.sql @@ -1,5 +1,5 @@ INSERT INTO polar_products ("polar_product_id", "sku_id", "name", "description", "interval", - "price_gbp", "features", "highlighted", "sort_order", "tier", "servers_permitted") -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + "price", "currency", "features", "highlighted", "sort_order", "tier", "servers_permitted") +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING "id", "polar_product_id", "sku_id", "name", "description", "interval", - "price_gbp", "features", "highlighted", "sort_order", "tier", "servers_permitted"; + "price", "currency", "features", "highlighted", "sort_order", "tier", "servers_permitted"; diff --git a/sql/polar_products/list_all.sql b/sql/polar_products/list_all.sql index 02a70b6..f2aae79 100644 --- a/sql/polar_products/list_all.sql +++ b/sql/polar_products/list_all.sql @@ -1,4 +1,4 @@ SELECT "id", "polar_product_id", "sku_id", "name", "description", "interval", - "price_gbp", "features", "highlighted", "sort_order", "tier", "servers_permitted" + "price", "currency", "features", "highlighted", "sort_order", "tier", "servers_permitted" FROM polar_products -ORDER BY "sort_order", "price_gbp"; +ORDER BY "sort_order", "price"; diff --git a/sql/polar_products/schema.sql b/sql/polar_products/schema.sql index e3d9ab4..2ec3af2 100644 --- a/sql/polar_products/schema.sql +++ b/sql/polar_products/schema.sql @@ -5,7 +5,8 @@ CREATE TABLE IF NOT EXISTS polar_products ( "name" TEXT NOT NULL, "description" TEXT NOT NULL DEFAULT '', "interval" TEXT NOT NULL DEFAULT 'month', - "price_gbp" INT NOT NULL, + "price" INT NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'usd', "features" TEXT[] NOT NULL DEFAULT '{}', "highlighted" BOOLEAN NOT NULL DEFAULT false, "sort_order" INT NOT NULL DEFAULT 0, diff --git a/sql/polar_products/update.sql b/sql/polar_products/update.sql index d4400cb..d15b89c 100644 --- a/sql/polar_products/update.sql +++ b/sql/polar_products/update.sql @@ -4,10 +4,11 @@ SET "polar_product_id" = $2, "name" = $4, "description" = $5, "interval" = $6, - "price_gbp" = $7, - "features" = $8, - "highlighted" = $9, - "sort_order" = $10, - "tier" = $11, - "servers_permitted" = $12 + "price" = $7, + "currency" = $8, + "features" = $9, + "highlighted" = $10, + "sort_order" = $11, + "tier" = $12, + "servers_permitted" = $13 WHERE "id" = $1; From 8fc151a524658babd60ba66140ca313975b954cd Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:27:02 +0200 Subject: [PATCH 26/49] fix: use live guild counts for integrations instead of stale materialized view --- cmd/viewrefresher/envvars.md | 2 - cmd/viewrefresher/main.go | 49 ----------------------- custom_integration.go | 30 +++++++++++--- custom_integration_guild_counts.go | 63 ------------------------------ database.go | 9 ----- view.go | 7 ---- 6 files changed, 25 insertions(+), 135 deletions(-) delete mode 100644 cmd/viewrefresher/envvars.md delete mode 100644 cmd/viewrefresher/main.go delete mode 100644 custom_integration_guild_counts.go delete mode 100644 view.go diff --git a/cmd/viewrefresher/envvars.md b/cmd/viewrefresher/envvars.md deleted file mode 100644 index fd6b8a7..0000000 --- a/cmd/viewrefresher/envvars.md +++ /dev/null @@ -1,2 +0,0 @@ -- DATABASE_URI -- DAEMON \ No newline at end of file diff --git a/cmd/viewrefresher/main.go b/cmd/viewrefresher/main.go deleted file mode 100644 index 922ec2d..0000000 --- a/cmd/viewrefresher/main.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "context" - "os" - "time" - - "github.com/TicketsBot-cloud/database" - "github.com/jackc/pgx/v4/pgxpool" - "github.com/sirupsen/logrus" -) - -func main() { - ctx := context.Background() - - logrus.Info("Connecting to database...") - pool := must(pgxpool.Connect(ctx, os.Getenv("DATABASE_URI"))) - db := database.NewDatabase(pool) - logrus.Info("Connected!") - - if os.Getenv("DAEMON") == "true" { - for { - doRefresh(ctx, db) - time.Sleep(6 * time.Hour) - } - } else { - doRefresh(ctx, db) - } -} - -func doRefresh(ctx context.Context, db *database.Database) { - logrus.Info("Starting refresh...") - - for _, view := range db.Views() { - if err := view.Refresh(ctx); err != nil { - logrus.Errorf("Error refreshing view: %s", err.Error()) - } - } - - logrus.Info("Refresh complete") -} - -func must[T any](v T, err error) T { - if err != nil { - panic(err) - } - - return v -} diff --git a/custom_integration.go b/custom_integration.go index f75341f..514c525 100644 --- a/custom_integration.go +++ b/custom_integration.go @@ -173,7 +173,11 @@ SELECT integrations.reviewed_at, COALESCE(counts.count, 0) AS guild_count FROM custom_integrations AS integrations -LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id WHERE "owner_id" = $1;` rows, err := i.Query(ctx, query, ownerId) @@ -231,7 +235,11 @@ SELECT integrations.reviewed_at, COALESCE(counts.count, 0) AS guild_count FROM custom_integrations AS integrations -LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id WHERE integrations.public = TRUE AND integrations.approved = FALSE AND integrations.rejection_reason IS NULL ORDER BY integrations.id DESC LIMIT $1 OFFSET $2;` @@ -258,7 +266,11 @@ SELECT integrations.reviewed_at, COALESCE(counts.count, 0) AS guild_count FROM custom_integrations AS integrations -LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id WHERE integrations.approved = TRUE ORDER BY integrations.id DESC LIMIT $1 OFFSET $2;` @@ -285,7 +297,11 @@ SELECT integrations.reviewed_at, COALESCE(counts.count, 0) AS guild_count FROM custom_integrations AS integrations -LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id WHERE integrations.rejection_reason IS NOT NULL ORDER BY integrations.reviewed_at DESC NULLS LAST LIMIT $1 OFFSET $2;` @@ -377,7 +393,11 @@ SELECT CASE WHEN active.integration_id IS NOT NULL THEN TRUE ELSE FALSE END AS added FROM custom_integrations as integrations LEFT OUTER JOIN active ON active.integration_id = integrations.id -LEFT OUTER JOIN custom_integration_guild_counts counts ON integrations.id = counts.integration_id +LEFT JOIN ( + SELECT integration_id, COUNT(*)::int AS count + FROM custom_integration_guilds + GROUP BY integration_id +) counts ON counts.integration_id = integrations.id WHERE active.integration_id IS NOT NULL OR ((integrations.public = 't' AND integrations.approved = 't') OR integrations.owner_id = $2) ORDER BY active.integration_id NULLS LAST, guild_count DESC diff --git a/custom_integration_guild_counts.go b/custom_integration_guild_counts.go deleted file mode 100644 index 4b28f0a..0000000 --- a/custom_integration_guild_counts.go +++ /dev/null @@ -1,63 +0,0 @@ -package database - -import ( - "context" - "fmt" - "github.com/jackc/pgx/v4/pgxpool" -) - -type CustomIntegrationGuildCountsView struct { - *pgxpool.Pool -} - -func newCustomIntegrationGuildCountsView(db *pgxpool.Pool) *CustomIntegrationGuildCountsView { - return &CustomIntegrationGuildCountsView{ - db, - } -} - -func (v CustomIntegrationGuildCountsView) Schema() string { - s := v.schema("custom_integration_guild_counts") - for _, indexSchema := range v.indexes("custom_integration_guild_counts") { - s += "\n" - s += indexSchema - } - - return s -} - -func (v CustomIntegrationGuildCountsView) schema(tableName string) string { - return fmt.Sprintf(` -CREATE MATERIALIZED VIEW IF NOT EXISTS %[1]s -AS - SELECT integration_id, COUNT(*) AS COUNT - FROM custom_integration_guilds - GROUP BY integration_id -WITH DATA; -`, tableName) -} - -func (v CustomIntegrationGuildCountsView) indexes(tableName string) []string { - return []string{ - fmt.Sprintf("CREATE UNIQUE INDEX IF NOT EXISTS %[1]s_integration_id_key ON %[1]s(integration_id);", tableName), - } -} - -func (v *CustomIntegrationGuildCountsView) Refresh(ctx context.Context) error { - statements := slice(v.schema("custom_integration_guild_counts_new")) - statements = append(statements, v.indexes("custom_integration_guild_counts_new")...) - statements = append(statements, - "DROP MATERIALIZED VIEW IF EXISTS custom_integration_guild_counts;", - "ALTER MATERIALIZED VIEW custom_integration_guild_counts_new RENAME TO custom_integration_guild_counts;", - "ALTER INDEX custom_integration_guild_counts_new_integration_id_key RENAME TO custom_integration_guild_counts_integration_id_key;", - ) - - tx, err := transact(ctx, v.Pool, statements...) - if err != nil { - return err - } - - defer tx.Rollback(ctx) - - return tx.Commit(ctx) -} diff --git a/database.go b/database.go index e3a60ae..5d04a8b 100644 --- a/database.go +++ b/database.go @@ -26,7 +26,6 @@ type Database struct { CloseReason *CloseMetadataTable CloseRequest *CloseRequestTable CustomIntegrations *CustomIntegrationTable - CustomIntegrationGuildCounts *CustomIntegrationGuildCountsView CustomIntegrationGuilds *CustomIntegrationGuildsTable CustomIntegrationHeaders *CustomIntegrationHeadersTable CustomIntegrationPlaceholders *CustomIntegrationPlaceholdersTable @@ -136,7 +135,6 @@ func NewDatabase(pool *pgxpool.Pool) *Database { CloseReason: newCloseReasonTable(pool), CloseRequest: newCloseRequestTable(pool), CustomIntegrations: newCustomIntegrationTable(pool), - CustomIntegrationGuildCounts: newCustomIntegrationGuildCountsView(pool), CustomIntegrationGuilds: newCustomIntegrationGuildsTable(pool), CustomIntegrationHeaders: newCustomIntegrationHeadersTable(pool), CustomIntegrationPlaceholders: newCustomIntegrationPlaceholdersTable(pool), @@ -265,7 +263,6 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.ClaimSettings, d.CustomIntegrations, d.CustomIntegrationGuilds, - d.CustomIntegrationGuildCounts, d.CustomIntegrationHeaders, d.CustomIntegrationPlaceholders, d.CustomIntegrationSecrets, @@ -366,12 +363,6 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { ) } -func (d *Database) Views() []View { - return []View{ - d.CustomIntegrationGuildCounts, - } -} - func mustCreate(ctx context.Context, pool *pgxpool.Pool, tables ...Table) { for _, table := range tables { if _, err := pool.Exec(ctx, table.Schema()); err != nil { diff --git a/view.go b/view.go deleted file mode 100644 index 504dab8..0000000 --- a/view.go +++ /dev/null @@ -1,7 +0,0 @@ -package database - -import "context" - -type View interface { - Refresh(ctx context.Context) error -} From 2681dcbc37ad6864fb9eea10b87b5243b8ae83f1 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 21 Jun 2026 21:24:42 +0100 Subject: [PATCH 27/49] extra method for wl bots Signed-off-by: Ben --- whitelabel.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/whitelabel.go b/whitelabel.go index f266a40..d460f31 100644 --- a/whitelabel.go +++ b/whitelabel.go @@ -88,3 +88,24 @@ func (w *WhitelabelBotTable) DeleteByToken(ctx context.Context, token string) er _, err := w.Exec(ctx, query, token) return err } + +func (w *WhitelabelBotTable) GetBotsBySharder(ctx context.Context, sharderTotal, sharderID int) ([]WhitelabelBot, error) { + query := `SELECT "user_id", "bot_id", "public_key", "token" FROM whitelabel WHERE "bot_id" % $1 = $2;` + + rows, err := w.Query(ctx, query, sharderTotal, sharderID) + if err != nil { + return nil, err + } + defer rows.Close() + + var bots []WhitelabelBot + for rows.Next() { + var bot WhitelabelBot + if err := rows.Scan(&bot.UserId, &bot.BotId, &bot.PublicKey, &bot.Token); err != nil { + return nil, err + } + bots = append(bots, bot) + } + + return bots, nil +} From 3bf71f58cf34d1d155a52e8a688c22d902bb94d5 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:37:37 +0200 Subject: [PATCH 28/49] Add public integration owner lookup Add a table helper that returns distinct owner IDs for integrations marked public, so public-integration authors can stay cached. --- custom_integration.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/custom_integration.go b/custom_integration.go index 514c525..3849f58 100644 --- a/custom_integration.go +++ b/custom_integration.go @@ -154,6 +154,29 @@ func (i *CustomIntegrationTable) GetOwnedCount(ctx context.Context, userId uint6 return } +// ListPublicOwnerIds returns distinct owner IDs of all integrations flagged public +// (pending review or approved). Used to keep public-integration authors cached. +func (i *CustomIntegrationTable) ListPublicOwnerIds(ctx context.Context) ([]uint64, error) { + query := `SELECT DISTINCT "owner_id" FROM custom_integrations WHERE "public" = TRUE;` + + rows, err := i.Query(ctx, query) + if err != nil { + return nil, err + } + + var ownerIds []uint64 + for rows.Next() { + var ownerId uint64 + if err := rows.Scan(&ownerId); err != nil { + return nil, err + } + + ownerIds = append(ownerIds, ownerId) + } + + return ownerIds, nil +} + func (i *CustomIntegrationTable) GetAllOwned(ctx context.Context, ownerId uint64) ([]CustomIntegrationWithGuildCount, error) { query := ` SELECT From 080a7542a0c87d2e0d2f32e7f93e2b9498f227a1 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:47:00 +0200 Subject: [PATCH 29/49] Add whitelabel resync audit action Adds a new `AuditActionWhitelabelResync` constant with ID 215 for logging whitelabel resync events. --- auditlog.go | 1 + 1 file changed, 1 insertion(+) diff --git a/auditlog.go b/auditlog.go index 2de25e0..41e7fd2 100644 --- a/auditlog.go +++ b/auditlog.go @@ -85,6 +85,7 @@ const ( AuditActionWhitelabelCreateInteractions AuditActionType = 212 AuditActionWhitelabelStatusSet AuditActionType = 213 AuditActionWhitelabelStatusDelete AuditActionType = 214 + AuditActionWhitelabelResync AuditActionType = 215 AuditActionBotStaffAdd AuditActionType = 300 AuditActionBotStaffRemove AuditActionType = 301 From c59120e5efdc8f18ee5c49978a1371b2f9160058 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 15 Jul 2026 17:39:01 +0100 Subject: [PATCH 30/49] feat: add description to kb article Signed-off-by: Ben --- kbarticle.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/kbarticle.go b/kbarticle.go index 3f50567..8893cf2 100644 --- a/kbarticle.go +++ b/kbarticle.go @@ -14,6 +14,7 @@ type KBArticle struct { GuildId uint64 `json:"guild_id,string"` Title string `json:"title"` Slug string `json:"slug"` + Description *string `json:"description"` Content *string `json:"content"` Embed *CustomEmbedWithFields `json:"embed"` CategoryIds []int `json:"category_ids"` @@ -41,6 +42,7 @@ CREATE TABLE IF NOT EXISTS kb_articles( "guild_id" int8 NOT NULL, "title" varchar(100) NOT NULL, "slug" varchar(100) NOT NULL, + "description" varchar(255) DEFAULT NULL, "content" text DEFAULT NULL CONSTRAINT kb_content_length CHECK (length(content) <= 4096), "embed" JSONB DEFAULT NULL, "category_ids" int4[] DEFAULT '{}', @@ -75,7 +77,7 @@ CREATE TRIGGER kb_articles_search_vector_trigger ` } -var kbArticleColumns = `"id", "guild_id", "title", "slug", "content", "embed", "category_ids", "keywords", "position", "published", "created_at", "updated_at"` +var kbArticleColumns = `"id", "guild_id", "title", "slug", "description", "content", "embed", "category_ids", "keywords", "position", "published", "created_at", "updated_at"` func scanKBArticle(row pgx.Row) (KBArticle, error) { var article KBArticle @@ -88,6 +90,7 @@ func scanKBArticle(row pgx.Row) (KBArticle, error) { &article.GuildId, &article.Title, &article.Slug, + &article.Description, &article.Content, &embedRaw, &categoryIds, @@ -296,8 +299,8 @@ type KBArticleSummary struct { func (t *KBArticlesTable) Create(ctx context.Context, article KBArticle) (int, error) { query := ` -INSERT INTO kb_articles("guild_id", "title", "slug", "content", "embed", "category_ids", "keywords", "position", "published") -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) +INSERT INTO kb_articles("guild_id", "title", "slug", "description", "content", "embed", "category_ids", "keywords", "position", "published") +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING "id"; ` @@ -318,6 +321,7 @@ RETURNING "id"; article.GuildId, article.Title, article.Slug, + article.Description, article.Content, embedRaw, categoryIds, @@ -331,8 +335,8 @@ RETURNING "id"; func (t *KBArticlesTable) Update(ctx context.Context, article KBArticle) error { query := ` UPDATE kb_articles -SET "title" = $2, "slug" = $3, "content" = $4, "embed" = $5, "category_ids" = $6, "keywords" = $7, "position" = $8, "published" = $9, "updated_at" = NOW() -WHERE "id" = $1 AND "guild_id" = $10; +SET "title" = $2, "slug" = $3, "description" = $4, "content" = $5, "embed" = $6, "category_ids" = $7, "keywords" = $8, "position" = $9, "published" = $10, "updated_at" = NOW() +WHERE "id" = $1 AND "guild_id" = $11; ` var embedRaw *string @@ -351,6 +355,7 @@ WHERE "id" = $1 AND "guild_id" = $10; article.Id, article.Title, article.Slug, + article.Description, article.Content, embedRaw, categoryIds, From dea9a1778ff7bffb181c13b321e696c7cc5e0a41 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 18 Jul 2026 22:32:16 +0100 Subject: [PATCH 31/49] add article feedback Signed-off-by: Ben --- database.go | 3 +++ kbarticlefeedback.go | 60 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 kbarticlefeedback.go diff --git a/database.go b/database.go index 5d04a8b..d028cdc 100644 --- a/database.go +++ b/database.go @@ -56,6 +56,7 @@ type Database struct { GuildLeaveTime *GuildLeaveTime GuildMetadata *GuildMetadataTable KBArticles *KBArticlesTable + KBArticleFeedback *KBArticleFeedbackTable KBCategories *KBCategoriesTable KBSettings *KBSettingsTable LegacyPremiumEntitlementGuilds *LegacyPremiumEntitlementGuilds @@ -165,6 +166,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { GuildLeaveTime: newGuildLeaveTime(pool), GuildMetadata: newGuildMetadataTable(pool), KBArticles: newKBArticles(pool), + KBArticleFeedback: newKBArticleFeedback(pool), KBCategories: newKBCategories(pool), KBSettings: newKBSettings(pool), LegacyPremiumEntitlementGuilds: newLegacyPremiumEntitlementGuildsTable(pool), @@ -323,6 +325,7 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.Tag, d.KBCategories, // Knowledge base categories d.KBArticles, // Knowledge base articles (references categories) + d.KBArticleFeedback, // Must be created after kb_articles (references articles) d.KBSettings, // Knowledge base customisation settings d.PanelKBCategories, // Panel-to-KB-category associations d.Tickets, // Must be created before members table diff --git a/kbarticlefeedback.go b/kbarticlefeedback.go new file mode 100644 index 0000000..f3c0ff5 --- /dev/null +++ b/kbarticlefeedback.go @@ -0,0 +1,60 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4/pgxpool" +) + +type KBArticleFeedbackTable struct { + *pgxpool.Pool +} + +func newKBArticleFeedback(db *pgxpool.Pool) *KBArticleFeedbackTable { + return &KBArticleFeedbackTable{ + Pool: db, + } +} + +func (t KBArticleFeedbackTable) Schema() string { + return ` +CREATE TABLE IF NOT EXISTS kb_article_feedback( + "id" serial NOT NULL, + "guild_id" int8 NOT NULL, + "article_id" int4 NOT NULL, + "panel_id" int4 NULL, + "user_id" int8 NOT NULL, + "helpful" bool NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY("article_id") REFERENCES kb_articles("id") ON DELETE CASCADE, + PRIMARY KEY("id") +); +CREATE UNIQUE INDEX IF NOT EXISTS kb_article_feedback_article_user_uindex ON kb_article_feedback("article_id", "user_id"); +CREATE INDEX IF NOT EXISTS kb_article_feedback_guild_id_idx ON kb_article_feedback("guild_id"); +` +} + +// Set records a single feedback vote for an article. Feedback is deduplicated per +// (article, user): a user's first vote is kept and later clicks are ignored, so the +// count reflects distinct users rather than clicks. +func (t *KBArticleFeedbackTable) Set(ctx context.Context, guildId uint64, articleId int, panelId *int, userId uint64, helpful bool) error { + query := ` +INSERT INTO kb_article_feedback("guild_id", "article_id", "panel_id", "user_id", "helpful") +VALUES($1, $2, $3, $4, $5) +ON CONFLICT("article_id", "user_id") DO NOTHING;` + + _, err := t.Exec(ctx, query, guildId, articleId, panelId, userId, helpful) + return err +} + +// CountHelpful returns the number of distinct users who marked an article as helpful. +func (t *KBArticleFeedbackTable) CountHelpful(ctx context.Context, articleId int) (int, error) { + query := `SELECT COUNT(*) FROM kb_article_feedback WHERE "article_id" = $1 AND "helpful" = true;` + + var count int + if err := t.QueryRow(ctx, query, articleId).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} From e9df4ef7c160d0894149c73f3c36da7b760aa2d1 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:50:29 +0200 Subject: [PATCH 32/49] Add GetAll method for whitelabel bots Introduce `WhitelabelBotTable.GetAll` to fetch every row from the `whitelabel` table and map results into `[]WhitelabelBot`. This adds a straightforward way to list all whitelabel bots without sharder filtering. --- whitelabel.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/whitelabel.go b/whitelabel.go index d460f31..ac9ca32 100644 --- a/whitelabel.go +++ b/whitelabel.go @@ -89,6 +89,27 @@ func (w *WhitelabelBotTable) DeleteByToken(ctx context.Context, token string) er return err } +func (w *WhitelabelBotTable) GetAll(ctx context.Context) ([]WhitelabelBot, error) { + query := `SELECT "user_id", "bot_id", "public_key", "token" FROM whitelabel;` + + rows, err := w.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var bots []WhitelabelBot + for rows.Next() { + var bot WhitelabelBot + if err := rows.Scan(&bot.UserId, &bot.BotId, &bot.PublicKey, &bot.Token); err != nil { + return nil, err + } + bots = append(bots, bot) + } + + return bots, nil +} + func (w *WhitelabelBotTable) GetBotsBySharder(ctx context.Context, sharderTotal, sharderID int) ([]WhitelabelBot, error) { query := `SELECT "user_id", "bot_id", "public_key", "token" FROM whitelabel WHERE "bot_id" % $1 = $2;` From dcfee8b08a241ed04dac493f82f57e2403293c06 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:01:36 +0200 Subject: [PATCH 33/49] Add atomic ticket claim support Introduce `TryClaim` for ticket claims so claim attempts are atomic and return the existing claimer when the ticket is already claimed. Also add audit log action IDs for ticket claim and unclaim events. --- auditlog.go | 2 ++ ticketclaims.go | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/auditlog.go b/auditlog.go index 41e7fd2..40b14f1 100644 --- a/auditlog.go +++ b/auditlog.go @@ -57,6 +57,8 @@ const ( AuditActionTicketClose AuditActionType = 92 AuditActionTicketCloseReasonUpdate AuditActionType = 93 AuditActionTicketCloseRequest AuditActionType = 94 + AuditActionTicketClaim AuditActionType = 95 + AuditActionTicketUnclaim AuditActionType = 96 AuditActionGuildIntegrationActivate AuditActionType = 100 AuditActionGuildIntegrationUpdate AuditActionType = 101 diff --git a/ticketclaims.go b/ticketclaims.go index cc9f8a5..8b44dba 100644 --- a/ticketclaims.go +++ b/ticketclaims.go @@ -45,6 +45,26 @@ func (c *TicketClaims) Set(ctx context.Context, guildId uint64, ticketId int, us return } +// TryClaim atomically claims the ticket only if it is currently unclaimed. It returns +// claimed=true if the claim was recorded, or claimed=false with the existing claimer if it +// was already claimed by anyone. +func (c *TicketClaims) TryClaim(ctx context.Context, guildId uint64, ticketId int, userId uint64) (claimed bool, claimer uint64, err error) { + query := `INSERT INTO ticket_claims("guild_id", "ticket_id", "user_id") VALUES($1, $2, $3) ON CONFLICT("guild_id", "ticket_id") DO NOTHING;` + + tag, err := c.Exec(ctx, query, guildId, ticketId, userId) + if err != nil { + return false, 0, err + } + + if tag.RowsAffected() > 0 { + return true, userId, nil + } + + // Already claimed - return the current claimer + claimer, err = c.Get(ctx, guildId, ticketId) + return false, claimer, err +} + func (c *TicketClaims) Delete(ctx context.Context, guildId uint64, ticketId int) (err error) { query := `DELETE FROM ticket_claims WHERE "guild_id"=$1 AND "ticket_id"=$2;` _, err = c.Exec(ctx, query, guildId, ticketId) From 318a922ce7660779c64176fa4cda1059b6bb268f Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:04:59 +0200 Subject: [PATCH 34/49] Add ticket transfer audit action Adds the `AuditActionTicketTransfer` constant with ID 97 to support logging ticket transfer events. --- auditlog.go | 1 + 1 file changed, 1 insertion(+) diff --git a/auditlog.go b/auditlog.go index 40b14f1..285fd5a 100644 --- a/auditlog.go +++ b/auditlog.go @@ -59,6 +59,7 @@ const ( AuditActionTicketCloseRequest AuditActionType = 94 AuditActionTicketClaim AuditActionType = 95 AuditActionTicketUnclaim AuditActionType = 96 + AuditActionTicketTransfer AuditActionType = 97 AuditActionGuildIntegrationActivate AuditActionType = 100 AuditActionGuildIntegrationUpdate AuditActionType = 101 From 26f68fb684152206912889c9e99849d871f691af Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:27:43 +0200 Subject: [PATCH 35/49] Filter notifications by category list Update notification listing and counting to accept a category slice instead of a single category. The SQL now matches any category in the provided array while still returning all notifications when the filter is nil. --- notifications.go | 8 ++++---- sql/notifications/count_by_user_id.sql | 2 +- sql/notifications/list_by_user_id.sql | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/notifications.go b/notifications.go index a216aff..d41b182 100644 --- a/notifications.go +++ b/notifications.go @@ -80,8 +80,8 @@ func (t *NotificationsTable) Create(ctx context.Context, userId uint64, category }, nil } -func (t *NotificationsTable) ListByUserId(ctx context.Context, userId uint64, category *string, limit, offset int) ([]Notification, error) { - rows, err := t.Query(ctx, notificationsListByUserId, userId, category, limit, offset) +func (t *NotificationsTable) ListByUserId(ctx context.Context, userId uint64, categories []string, limit, offset int) ([]Notification, error) { + rows, err := t.Query(ctx, notificationsListByUserId, userId, categories, limit, offset) if err != nil { return nil, err } @@ -108,9 +108,9 @@ func (t *NotificationsTable) CountUnreadByUserId(ctx context.Context, userId uin return count, nil } -func (t *NotificationsTable) CountByUserId(ctx context.Context, userId uint64, category *string) (int, error) { +func (t *NotificationsTable) CountByUserId(ctx context.Context, userId uint64, categories []string) (int, error) { var count int - if err := t.QueryRow(ctx, notificationsCountByUserId, userId, category).Scan(&count); err != nil { + if err := t.QueryRow(ctx, notificationsCountByUserId, userId, categories).Scan(&count); err != nil { return 0, err } return count, nil diff --git a/sql/notifications/count_by_user_id.sql b/sql/notifications/count_by_user_id.sql index f475104..8be4762 100644 --- a/sql/notifications/count_by_user_id.sql +++ b/sql/notifications/count_by_user_id.sql @@ -1,4 +1,4 @@ SELECT COUNT(*) FROM notifications WHERE "user_id" = $1 - AND ($2::TEXT IS NULL OR "category" = $2); + AND ($2::TEXT[] IS NULL OR "category" = ANY($2)); diff --git a/sql/notifications/list_by_user_id.sql b/sql/notifications/list_by_user_id.sql index 9aded7e..5396719 100644 --- a/sql/notifications/list_by_user_id.sql +++ b/sql/notifications/list_by_user_id.sql @@ -1,6 +1,6 @@ SELECT "id", "user_id", "category", "title", "body", "link", "read", "created_at" FROM notifications WHERE "user_id" = $1 - AND ($2::TEXT IS NULL OR "category" = $2) + AND ($2::TEXT[] IS NULL OR "category" = ANY($2)) ORDER BY "created_at" DESC LIMIT $3 OFFSET $4; From 9af08a07013419b1e760a8d8e520fc83105593bd Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:08:02 +0200 Subject: [PATCH 36/49] Order daily ticket counts chronologically Update the daily ticket stats query to sort by date ascending instead of descending. This returns results in chronological order, which is more suitable for time-series display and processing. --- tickets.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tickets.go b/tickets.go index 486d022..33812da 100644 --- a/tickets.go +++ b/tickets.go @@ -1375,7 +1375,7 @@ SELECT d::date AS date, COUNT(t.id) AS count FROM generate_series(CURRENT_DATE - ($2 - 1) * INTERVAL '1 day', CURRENT_DATE, '1 day') AS d LEFT JOIN tickets t ON t.guild_id = $1 AND date_trunc('day', t.open_time AT TIME ZONE 'UTC') = d::date GROUP BY d::date -ORDER BY d::date DESC;` +ORDER BY d::date;` rows, err := t.Query(ctx, query, guildId, nDays) if err != nil { From c3f3c23fb9b6f1984a5a4b1f583ce2fd4888b0a9 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 6 Aug 2026 10:16:30 +0100 Subject: [PATCH 37/49] feat: feature flags Signed-off-by: Ben --- auditlog.go | 5 ++ database.go | 3 + experimentexposures.go | 136 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 experimentexposures.go diff --git a/auditlog.go b/auditlog.go index 285fd5a..d165aa1 100644 --- a/auditlog.go +++ b/auditlog.go @@ -149,6 +149,10 @@ const ( AuditActionEmailVerify AuditActionType = 435 AuditActionEmailResendVerification AuditActionType = 436 + + AuditActionFeatureFlagToggle AuditActionType = 440 + AuditActionFeatureFlagCreate AuditActionType = 441 + AuditActionFeatureFlagRulesUpdate AuditActionType = 442 ) type AuditResourceType int16 @@ -191,6 +195,7 @@ const ( AuditResourceUserEmail AuditResourceType = 35 AuditResourceNotification AuditResourceType = 36 AuditResourceUserEmailVerification AuditResourceType = 37 + AuditResourceFeatureFlag AuditResourceType = 38 ) type AuditLogEntry struct { diff --git a/database.go b/database.go index d028cdc..87f16bb 100644 --- a/database.go +++ b/database.go @@ -43,6 +43,7 @@ type Database struct { Entitlements *Entitlements ExitSurveyResponses *ExitSurveyResponses Experiment *ExperimentTable + ExperimentExposures *ExperimentExposuresTable FirstResponseTime *FirstResponseTime FormInput *FormInputTable FormInputOption *FormInputOptionTable @@ -153,6 +154,7 @@ func NewDatabase(pool *pgxpool.Pool) *Database { Entitlements: newEntitlementsTable(pool), ExitSurveyResponses: newExitSurveyResponses(pool), Experiment: newExperimentTable(pool), + ExperimentExposures: newExperimentExposuresTable(pool), FirstResponseTime: newFirstResponseTime(pool), FormInput: newFormInputTable(pool), Forms: newFormsTable(pool), @@ -275,6 +277,7 @@ func (d *Database) CreateTables(ctx context.Context, pool *pgxpool.Pool) { d.EmbedFields, // depends on embeds d.Entitlements, d.Experiment, + d.ExperimentExposures, d.DiscordEntitlements, // depends on entitlements d.Skus, // must be created before discord_store_skus, subscription_skus, multi_server_skus, polar_products d.DiscordStoreSkus, // depends on skus diff --git a/experimentexposures.go b/experimentexposures.go new file mode 100644 index 0000000..4042a16 --- /dev/null +++ b/experimentexposures.go @@ -0,0 +1,136 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +// ExperimentExposure records that one unit was enrolled in one experiment. It is +// the assignment data GrowthBook reads when computing experiment results. +type ExperimentExposure struct { + // ExperimentKey is GrowthBook's string experiment key. Deliberately not named + // experiment_id: the unrelated legacy experiments table has an integer id + // column, and GrowthBook's assignment query aliases this to experiment_id. + ExperimentKey string `json:"experiment_key"` + VariationId int `json:"variation_id"` + IdentifierType string `json:"identifier_type"` + Identifier string `json:"identifier"` + FeatureKey string `json:"feature_key"` + ExposedAt time.Time `json:"exposed_at"` +} + +type ExperimentExposuresTable struct { + *pgxpool.Pool +} + +func newExperimentExposuresTable(db *pgxpool.Pool) *ExperimentExposuresTable { + return &ExperimentExposuresTable{ + db, + } +} + +// Schema deliberately declares no primary key. The table is append-only and +// written in batches from every worker pod, so a BIGSERIAL would add a contended +// sequence and an index write for a column nothing reads. +// +// Writers deduplicate to roughly one row per unit per experiment per day, but +// duplicates are still possible across a deduplication window boundary or if the +// Redis deduplication layer is unavailable. Any assignment query must therefore +// take the first exposure per unit rather than assume uniqueness, and alias +// experiment_key to the experiment_id name GrowthBook expects: +// +// SELECT identifier AS user_id, +// MIN(exposed_at) AS timestamp, +// experiment_key AS experiment_id, +// variation_id +// FROM experiment_exposures +// WHERE identifier_type = 'guild_id' +// GROUP BY identifier, experiment_key, variation_id +func (e ExperimentExposuresTable) Schema() string { + return ` + CREATE TABLE IF NOT EXISTS experiment_exposures( + "experiment_key" VARCHAR(255) NOT NULL, + "variation_id" INT NOT NULL, + "identifier_type" VARCHAR(64) NOT NULL, + "identifier" VARCHAR(64) NOT NULL, + "feature_key" VARCHAR(255) NOT NULL DEFAULT '', + "exposed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS experiment_exposures_experiment_exposed_at ON experiment_exposures("experiment_key", "exposed_at"); + CREATE INDEX IF NOT EXISTS experiment_exposures_exposed_at ON experiment_exposures("exposed_at"); + ` +} + +// InsertBatch appends exposures via COPY. Callers batch to keep the write cost +// off the event path. +func (e *ExperimentExposuresTable) InsertBatch(ctx context.Context, exposures []ExperimentExposure) error { + if len(exposures) == 0 { + return nil + } + + rows := make([][]interface{}, 0, len(exposures)) + for _, exposure := range exposures { + rows = append(rows, []interface{}{ + exposure.ExperimentKey, + exposure.VariationId, + exposure.IdentifierType, + exposure.Identifier, + exposure.FeatureKey, + exposure.ExposedAt, + }) + } + + _, err := e.CopyFrom( + ctx, + pgx.Identifier{"experiment_exposures"}, + []string{"experiment_key", "variation_id", "identifier_type", "identifier", "feature_key", "exposed_at"}, + pgx.CopyFromRows(rows), + ) + + return err +} + +// DeleteOlderThan enforces retention. Nothing calls this yet: it needs a caller +// in a daemon, otherwise the table grows without bound. +func (e *ExperimentExposuresTable) DeleteOlderThan(ctx context.Context, age time.Duration) (int64, error) { + query := `DELETE FROM experiment_exposures WHERE "exposed_at" < NOW() - $1::interval;` + + tag, err := e.Exec(ctx, query, age) + if err != nil { + return 0, err + } + + return tag.RowsAffected(), nil +} + +// CountByExperiment reports rows per variation for one experiment, for the admin +// UI to sanity-check that an experiment is actually collecting data. +func (e *ExperimentExposuresTable) CountByExperiment(ctx context.Context, experimentKey string) (map[int]int, error) { + query := ` + SELECT "variation_id", COUNT(DISTINCT "identifier") + FROM experiment_exposures + WHERE "experiment_key" = $1 + GROUP BY "variation_id"; + ` + + rows, err := e.Query(ctx, query, experimentKey) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[int]int) + for rows.Next() { + var variationId, count int + if err := rows.Scan(&variationId, &count); err != nil { + return nil, err + } + + counts[variationId] = count + } + + return counts, rows.Err() +} From be4c9ce295ba326586abdce714ae557f91db037a Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 6 Aug 2026 19:17:27 +0100 Subject: [PATCH 38/49] feat: panel performance & filtering Signed-off-by: Ben --- analyticstypes.go | 23 +++- closereason.go | 29 +++- firstresponsetime.go | 83 ++++++------ panelfilter.go | 85 ++++++++++++ serviceratings.go | 109 ++++++++++++--- ticketlabelassignments.go | 13 +- ticketmessagecounts.go | 76 +++++------ tickets.go | 279 +++++++++++++++++++++++--------------- 8 files changed, 469 insertions(+), 228 deletions(-) create mode 100644 panelfilter.go diff --git a/analyticstypes.go b/analyticstypes.go index ab078fa..002f61b 100644 --- a/analyticstypes.go +++ b/analyticstypes.go @@ -8,6 +8,27 @@ type TripleWindow struct { Weekly *time.Duration } +// MetricWindows extends TripleWindow with a selected-range aggregate. The +// selected window honours the exact days parameter from the query string, +// replacing the bucketed approach that previously picked the nearest fixed +// window and silently mislabelled it. +type MetricWindows struct { + Selected *time.Duration // equals AllTime when days == 0 + AllTime *time.Duration + Monthly *time.Duration + Weekly *time.Duration +} + +// TripleWindow drops the selected window, for callers that only need the +// three fixed buckets (the worker's /stats server command). +func (mw MetricWindows) TripleWindow() TripleWindow { + return TripleWindow{ + AllTime: mw.AllTime, + Monthly: mw.Monthly, + Weekly: mw.Weekly, + } +} + type CountOnDate struct { Date time.Time `json:"date"` Count uint64 `json:"count"` @@ -50,7 +71,7 @@ type PeakHourEntry struct { type SourceBreakdown struct { Source TicketSource `json:"source"` - Count int `json:"count"` + Count int `json:"count"` } type ResponseTimeByHour struct { diff --git a/closereason.go b/closereason.go index a7e7bff..c533192 100644 --- a/closereason.go +++ b/closereason.go @@ -2,6 +2,7 @@ package database import ( "context" + "fmt" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" @@ -156,7 +157,7 @@ type CloseReasonCount struct { Count int `json:"count"` } -func (c *CloseMetadataTable) GetTopCloseReasonsWithCount(ctx context.Context, guildId uint64, panelId *int, limit, days int) ([]CloseReasonCount, error) { +func (c *CloseMetadataTable) GetTopCloseReasonsWithCount(ctx context.Context, guildId uint64, panelId *int, limit, days int, filter *PanelFilter) ([]CloseReasonCount, error) { query := ` SELECT cr.close_reason, COUNT(*)::int AS count FROM close_reason cr @@ -166,12 +167,18 @@ WHERE cr.guild_id = $1 AND cr.close_reason IS NOT NULL AND cr.close_reason != '' AND cr.close_reason != 'Automatically closed due to inactivity' - AND ($4 = 0 OR t.open_time > NOW() - make_interval(days => $4)) + AND ($4 = 0 OR t.open_time > NOW() - make_interval(days => $4))` + + PanelPredicate("t", 5, 6) + ` GROUP BY cr.close_reason ORDER BY COUNT(*) DESC LIMIT $3;` - rows, err := c.Query(ctx, query, guildId, panelId, limit, days) + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("top close reasons: %w", err) + } + + rows, err := c.Query(ctx, query, guildId, panelId, limit, days, arr, unassigned) if err != nil { return nil, err } @@ -189,17 +196,29 @@ LIMIT $3;` return results, nil } +// GetAutoCloseVsManualClose is the worker-compatible wrapper. Delegates to +// GetAutoCloseVsManualCloseFiltered with no panel filter. func (c *CloseMetadataTable) GetAutoCloseVsManualClose(ctx context.Context, guildId uint64, nDays int) (AutoCloseStats, error) { + return c.GetAutoCloseVsManualCloseFiltered(ctx, guildId, nDays, nil) +} + +func (c *CloseMetadataTable) GetAutoCloseVsManualCloseFiltered(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) (AutoCloseStats, error) { query := ` SELECT COUNT(*) FILTER (WHERE cr.closed_by IS NULL), COUNT(*) FILTER (WHERE cr.closed_by IS NOT NULL) FROM close_reason cr INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id -WHERE cr.guild_id = $1 AND t.close_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day';` +WHERE cr.guild_id = $1 AND t.close_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return AutoCloseStats{}, fmt.Errorf("auto close stats: %w", err) + } var stats AutoCloseStats - if err := c.QueryRow(ctx, query, guildId, nDays).Scan(&stats.AutoClosed, &stats.ManualClosed); err != nil { + if err := c.QueryRow(ctx, query, guildId, nDays, arr, unassigned).Scan(&stats.AutoClosed, &stats.ManualClosed); err != nil { return AutoCloseStats{}, err } diff --git a/firstresponsetime.go b/firstresponsetime.go index 64e1fdf..a021086 100644 --- a/firstresponsetime.go +++ b/firstresponsetime.go @@ -2,10 +2,12 @@ package database import ( "context" + "fmt" + "time" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" - "time" ) type FirstResponseTime struct { @@ -100,74 +102,65 @@ func (f *FirstResponseTime) GetAverageAllTimeUser(ctx context.Context, guildId, return } +// GetAverageTripleWindow returns the three fixed-window averages. It delegates +// to GetAverageWindows with days=0 and no panel filter, preserving the +// existing signature for the worker module. func (f *FirstResponseTime) GetAverageTripleWindow(ctx context.Context, guildId uint64) (TripleWindow, error) { + mw, err := f.GetAverageWindows(ctx, guildId, 0, nil) + if err != nil { + return TripleWindow{}, err + } + return mw.TripleWindow(), nil +} + +// GetAverageWindows returns four first-response-time averages in one round trip: +// the exact selected range, all time, monthly, and weekly. The selected window +// equals all-time when days == 0. +func (f *FirstResponseTime) GetAverageWindows(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (MetricWindows, error) { query := ` SELECT + AVG(frt.response_time) FILTER (WHERE $2 = 0 OR t.open_time > NOW() - make_interval(days => $2)), AVG(frt.response_time), AVG(frt.response_time) FILTER (WHERE t.open_time > NOW() - INTERVAL '30 days'), AVG(frt.response_time) FILTER (WHERE t.open_time > NOW() - INTERVAL '7 days') FROM first_response_time frt INNER JOIN tickets t ON frt.guild_id = t.guild_id AND frt.ticket_id = t.id -WHERE frt.guild_id = $1;` +WHERE frt.guild_id = $1` + PanelPredicate("t", 3, 4) - var allTime, monthly, weekly *time.Duration - if err := f.QueryRow(ctx, query, guildId).Scan(&allTime, &monthly, &weekly); err != nil { - return TripleWindow{}, err + arr, unassigned, err := filter.Args() + if err != nil { + return MetricWindows{}, fmt.Errorf("first response time windows: %w", err) + } + + var mw MetricWindows + if err := f.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan( + &mw.Selected, &mw.AllTime, &mw.Monthly, &mw.Weekly, + ); err != nil { + return MetricWindows{}, err } - return TripleWindow{ - AllTime: allTime, - Monthly: monthly, - Weekly: weekly, - }, nil + return mw, nil } -func (f *FirstResponseTime) GetAverageByHour(ctx context.Context, guildId uint64, days int) ([]ResponseTimeByHour, error) { - if days == 0 { - query := ` +func (f *FirstResponseTime) GetAverageByHour(ctx context.Context, guildId uint64, days int, filter *PanelFilter) ([]ResponseTimeByHour, error) { + query := ` SELECT EXTRACT(HOUR FROM t.open_time)::int AS hour_of_day, AVG(frt.response_time) AS avg_response_time FROM first_response_time frt INNER JOIN tickets t ON frt.guild_id = t.guild_id AND frt.ticket_id = t.id WHERE frt.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + ` GROUP BY hour_of_day ORDER BY hour_of_day;` - rows, err := f.Query(ctx, query, guildId) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []ResponseTimeByHour - for rows.Next() { - var r ResponseTimeByHour - if err := rows.Scan(&r.HourOfDay, &r.AvgResponseTime); err != nil { - return nil, err - } - results = append(results, r) - } - - return results, nil - } - - parsedInterval := pgtype.Interval{} - if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { - return nil, err + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("response time by hour: %w", err) } - query := ` -SELECT - EXTRACT(HOUR FROM t.open_time)::int AS hour_of_day, - AVG(frt.response_time) AS avg_response_time -FROM first_response_time frt -INNER JOIN tickets t ON frt.guild_id = t.guild_id AND frt.ticket_id = t.id -WHERE frt.guild_id = $1 AND t.open_time > NOW() - $2::interval -GROUP BY hour_of_day -ORDER BY hour_of_day;` - - rows, err := f.Query(ctx, query, guildId, parsedInterval) + rows, err := f.Query(ctx, query, guildId, days, arr, unassigned) if err != nil { return nil, err } diff --git a/panelfilter.go b/panelfilter.go new file mode 100644 index 0000000..8ed17a2 --- /dev/null +++ b/panelfilter.go @@ -0,0 +1,85 @@ +package database + +import ( + "fmt" + + "github.com/jackc/pgtype" +) + +// PanelFilter restricts analytics queries to a subset of panels. A nil +// *PanelFilter means no filtering (all panels, including unassigned). +type PanelFilter struct { + PanelIds []int + IncludeUnassigned bool +} + +// PanelPredicate renders a SQL predicate that filters on panel_id, prefixed +// with " AND ". The caller supplies the table alias and the two parameter +// numbers to bind against. Parameter numbers are stated explicitly rather than +// auto-incremented because several queries reference the predicate more than +// once and some bind parameters out of textual order. +// +// The clause consumes exactly two parameters: +// +// $arrayParam ::int4[] panel IDs, or NULL for "no filter" +// $unassignedParam ::bool whether to include tickets with panel_id IS NULL +func PanelPredicate(alias string, arrayParam, unassignedParam int) string { + return fmt.Sprintf( + ` AND ($%d::int4[] IS NULL OR %s.panel_id = ANY($%d::int4[]) OR ($%d::bool AND %s.panel_id IS NULL))`, + arrayParam, alias, arrayParam, unassignedParam, alias, + ) +} + +// Args returns the two bound values for PanelPredicate: the int4 array and the +// bool. Safe on a nil receiver, where it returns the "no filter" pair (NULL +// array, false). +// +// The empty-versus-NULL distinction matters and is the highest-probability +// silent bug in this change: +// +// Set(nil) -> Status: Null -> $N IS NULL matches everything +// Set([]int32{}) -> present, zero-dimension -> $N IS NULL is false, ANY($N) matches nothing +// +// Getting this backwards makes "unassigned only" silently mean "everything". +func (f *PanelFilter) Args() (pgtype.Int4Array, bool, error) { + if f == nil { + return pgtype.Int4Array{Status: pgtype.Null}, false, nil + } + + if len(f.PanelIds) == 0 && !f.IncludeUnassigned { + // Neither panels nor unassigned selected: no filter. + return pgtype.Int4Array{Status: pgtype.Null}, false, nil + } + + var arr pgtype.Int4Array + + if len(f.PanelIds) == 0 { + // IncludeUnassigned is true but no panel IDs: "unassigned only". + // Must be present-empty (not NULL) so the IS NULL check in the + // predicate correctly excludes all panel-assigned tickets. + if err := arr.Set([]int32{}); err != nil { + return pgtype.Int4Array{}, false, fmt.Errorf("panel filter: setting empty array: %w", err) + } + return arr, true, nil + } + + ids := make([]int32, len(f.PanelIds)) + for i, id := range f.PanelIds { + ids[i] = int32(id) + } + + if err := arr.Set(ids); err != nil { + return pgtype.Int4Array{}, false, fmt.Errorf("panel filter: setting array: %w", err) + } + + return arr, f.IncludeUnassigned, nil +} + +// IsActive reports whether the filter narrows anything. A nil filter is not +// active (it matches all tickets). +func (f *PanelFilter) IsActive() bool { + if f == nil { + return false + } + return len(f.PanelIds) > 0 || f.IncludeUnassigned +} diff --git a/serviceratings.go b/serviceratings.go index 1e0dd0e..51e9d0b 100644 --- a/serviceratings.go +++ b/serviceratings.go @@ -2,8 +2,9 @@ package database import ( "context" + "fmt" - "github.com/jackc/pgx/pgtype" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" ) @@ -42,10 +43,33 @@ func (r *ServiceRatings) Get(ctx context.Context, guildId uint64, ticketId int) } } -func (r *ServiceRatings) GetCount(ctx context.Context, guildId uint64) (count int, err error) { - query := `SELECT COUNT(*) from service_ratings WHERE "guild_id" = $1;` - err = r.QueryRow(ctx, query, guildId).Scan(&count) - return +// GetCount is the worker-compatible wrapper. Delegates to GetCountFiltered +// with days=0 and no panel filter. +func (r *ServiceRatings) GetCount(ctx context.Context, guildId uint64) (int, error) { + return r.GetCountFiltered(ctx, guildId, 0, nil) +} + +// GetCountFiltered returns the count of feedback ratings, optionally scoped by +// time window and panel filter. +func (r *ServiceRatings) GetCountFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (int, error) { + query := ` +SELECT COUNT(*) +FROM service_ratings sr +INNER JOIN tickets t ON sr.guild_id = t.guild_id AND sr.ticket_id = t.id +WHERE sr.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return 0, fmt.Errorf("feedback count: %w", err) + } + + var count int + if err := r.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan(&count); err != nil { + return 0, err + } + return count, nil } func (r *ServiceRatings) GetCountClaimedBy(ctx context.Context, guildId, userId uint64) (count int, err error) { @@ -61,17 +85,36 @@ WHERE service_ratings.guild_id = $1 AND ticket_claims.user_id = $2; return } -func (r *ServiceRatings) GetAverage(ctx context.Context, guildId uint64) (average float32, err error) { - // Returns NULL if no ratings - var f *float32 +// GetAverage is the worker-compatible wrapper. Delegates to +// GetAverageFiltered with days=0 and no panel filter. +func (r *ServiceRatings) GetAverage(ctx context.Context, guildId uint64) (float32, error) { + return r.GetAverageFiltered(ctx, guildId, 0, nil) +} - query := `SELECT AVG(rating) from service_ratings WHERE "guild_id" = $1;` - err = r.QueryRow(ctx, query, guildId).Scan(&f) - if f != nil { - average = *f +// GetAverageFiltered returns the average feedback rating, optionally scoped by +// time window and panel filter. +func (r *ServiceRatings) GetAverageFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (float32, error) { + query := ` +SELECT AVG(sr.rating) +FROM service_ratings sr +INNER JOIN tickets t ON sr.guild_id = t.guild_id AND sr.ticket_id = t.id +WHERE sr.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return 0, fmt.Errorf("average rating: %w", err) } - return + var f *float32 + if err := r.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan(&f); err != nil { + return 0, err + } + if f != nil { + return *f, nil + } + return 0, nil } func (r *ServiceRatings) GetAverageClaimedBy(ctx context.Context, guildId, userId uint64) (average float32, err error) { @@ -186,10 +229,30 @@ GROUP BY sr.rating ORDER BY sr.rating;` return dist, nil } +// GetDistribution is the worker-compatible wrapper. Delegates to +// GetDistributionFiltered with days=0 and no panel filter. func (r *ServiceRatings) GetDistribution(ctx context.Context, guildId uint64) ([5]int, error) { - query := `SELECT rating, COUNT(*) FROM service_ratings WHERE guild_id = $1 GROUP BY rating ORDER BY rating;` + return r.GetDistributionFiltered(ctx, guildId, 0, nil) +} + +// GetDistributionFiltered returns the 1-5 star distribution, optionally +// scoped by time window and panel filter. +func (r *ServiceRatings) GetDistributionFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) ([5]int, error) { + query := ` +SELECT sr.rating, COUNT(*) +FROM service_ratings sr +INNER JOIN tickets t ON sr.guild_id = t.guild_id AND sr.ticket_id = t.id +WHERE sr.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + ` +GROUP BY sr.rating ORDER BY sr.rating;` - rows, err := r.Query(ctx, query, guildId) + arr, unassigned, err := filter.Args() + if err != nil { + return [5]int{}, fmt.Errorf("feedback distribution: %w", err) + } + + rows, err := r.Query(ctx, query, guildId, days, arr, unassigned) if err != nil { return [5]int{}, err } @@ -209,16 +272,28 @@ func (r *ServiceRatings) GetDistribution(ctx context.Context, guildId uint64) ([ return dist, nil } +// GetResponseRate is the worker-compatible wrapper. Delegates to +// GetResponseRateFiltered with no panel filter. func (r *ServiceRatings) GetResponseRate(ctx context.Context, guildId uint64, nDays int) (FeedbackResponseRate, error) { + return r.GetResponseRateFiltered(ctx, guildId, nDays, nil) +} + +func (r *ServiceRatings) GetResponseRateFiltered(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) (FeedbackResponseRate, error) { query := ` SELECT COUNT(t.id) AS closed, COUNT(sr.rating) AS rated FROM tickets t LEFT JOIN service_ratings sr ON t.guild_id = sr.guild_id AND t.id = sr.ticket_id WHERE t.guild_id = $1 AND t.open = false - AND t.close_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day';` + AND t.close_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return FeedbackResponseRate{}, fmt.Errorf("feedback response rate: %w", err) + } var result FeedbackResponseRate - if err := r.QueryRow(ctx, query, guildId, nDays).Scan(&result.ClosedTickets, &result.RatedTickets); err != nil { + if err := r.QueryRow(ctx, query, guildId, nDays, arr, unassigned).Scan(&result.ClosedTickets, &result.RatedTickets); err != nil { return FeedbackResponseRate{}, err } diff --git a/ticketlabelassignments.go b/ticketlabelassignments.go index 895f50c..fa2736f 100644 --- a/ticketlabelassignments.go +++ b/ticketlabelassignments.go @@ -2,6 +2,7 @@ package database import ( "context" + "fmt" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4/pgxpool" @@ -141,17 +142,23 @@ func (t *TicketLabelAssignmentsTable) Replace(ctx context.Context, guildId uint6 return tx.Commit(ctx) } -func (t *TicketLabelAssignmentsTable) GetTicketCountByLabel(ctx context.Context, guildId uint64, nDays int) ([]LabelTicketCount, error) { +func (t *TicketLabelAssignmentsTable) GetTicketCountByLabel(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]LabelTicketCount, error) { query := ` SELECT tla.label_id, tl.name, tl.colour, COUNT(*) FROM ticket_label_assignments tla INNER JOIN ticket_labels tl ON tla.guild_id = tl.guild_id AND tla.label_id = tl.label_id INNER JOIN tickets t ON tla.guild_id = t.guild_id AND tla.ticket_id = t.id -WHERE tla.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day' +WHERE tla.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + ` GROUP BY tla.label_id, tl.name, tl.colour ORDER BY COUNT(*) DESC;` - rows, err := t.Query(ctx, query, guildId, nDays) + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("ticket count by label: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) if err != nil { return nil, err } diff --git a/ticketmessagecounts.go b/ticketmessagecounts.go index 19fc103..3ec124a 100644 --- a/ticketmessagecounts.go +++ b/ticketmessagecounts.go @@ -2,9 +2,8 @@ package database import ( "context" - "time" + "fmt" - "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" ) @@ -80,39 +79,32 @@ type AverageMessageCounts struct { AvgTotalMessages *float64 `json:"avg_total_messages"` } -func (t *TicketMessageCounts) GetAverageMessageCounts(ctx context.Context, guildId uint64, days int) (avg AverageMessageCounts, e error) { - if days == 0 { - query := ` +// GetAverageMessageCounts is the worker-compatible wrapper. Delegates to +// GetAverageMessageCountsFiltered with no panel filter. +func (t *TicketMessageCounts) GetAverageMessageCounts(ctx context.Context, guildId uint64, days int) (AverageMessageCounts, error) { + return t.GetAverageMessageCountsFiltered(ctx, guildId, days, nil) +} + +func (t *TicketMessageCounts) GetAverageMessageCountsFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (avg AverageMessageCounts, e error) { + query := ` SELECT AVG(tmc."staff_messages"), AVG(tmc."user_messages"), AVG(tmc."staff_messages" + tmc."user_messages") FROM ticket_message_counts tmc INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" -WHERE tmc."guild_id" = $1 AND t."open" = false;` - if err := t.QueryRow(ctx, query, guildId).Scan(&avg.AvgStaffMessages, &avg.AvgUserMessages, &avg.AvgTotalMessages); err != nil && err != pgx.ErrNoRows { - e = err - } - return - } +WHERE tmc."guild_id" = $1 AND t."open" = false + AND ($2 = 0 OR t."open_time" > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) - parsedInterval := pgtype.Interval{} - if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { - return avg, err + arr, unassigned, err := filter.Args() + if err != nil { + return avg, fmt.Errorf("average message counts: %w", err) } - query := ` -SELECT - AVG(tmc."staff_messages"), - AVG(tmc."user_messages"), - AVG(tmc."staff_messages" + tmc."user_messages") -FROM ticket_message_counts tmc -INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" -WHERE tmc."guild_id" = $1 AND t."open" = false AND t."open_time" > NOW() - $2::interval;` - if err := t.QueryRow(ctx, query, guildId, parsedInterval).Scan(&avg.AvgStaffMessages, &avg.AvgUserMessages, &avg.AvgTotalMessages); err != nil && err != pgx.ErrNoRows { + if err := t.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan(&avg.AvgStaffMessages, &avg.AvgUserMessages, &avg.AvgTotalMessages); err != nil && err != pgx.ErrNoRows { e = err } - return } @@ -121,36 +113,30 @@ type OneTouchResolution struct { TotalClosed int `json:"total_closed"` } -func (t *TicketMessageCounts) GetOneTouchResolutionRate(ctx context.Context, guildId uint64, days int) (result OneTouchResolution, e error) { - if days == 0 { - query := ` +// GetOneTouchResolutionRate is the worker-compatible wrapper. Delegates to +// GetOneTouchResolutionRateFiltered with no panel filter. +func (t *TicketMessageCounts) GetOneTouchResolutionRate(ctx context.Context, guildId uint64, days int) (OneTouchResolution, error) { + return t.GetOneTouchResolutionRateFiltered(ctx, guildId, days, nil) +} + +func (t *TicketMessageCounts) GetOneTouchResolutionRateFiltered(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (result OneTouchResolution, e error) { + query := ` SELECT COUNT(*) FILTER (WHERE tmc."staff_messages" = 1), COUNT(*) FROM ticket_message_counts tmc INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" -WHERE tmc."guild_id" = $1 AND t."open" = false;` - if err := t.QueryRow(ctx, query, guildId).Scan(&result.OneTouchCount, &result.TotalClosed); err != nil && err != pgx.ErrNoRows { - e = err - } - return - } +WHERE tmc."guild_id" = $1 AND t."open" = false + AND ($2 = 0 OR t."open_time" > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) - parsedInterval := pgtype.Interval{} - if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { - return result, err + arr, unassigned, err := filter.Args() + if err != nil { + return result, fmt.Errorf("one touch resolution: %w", err) } - query := ` -SELECT - COUNT(*) FILTER (WHERE tmc."staff_messages" = 1), - COUNT(*) -FROM ticket_message_counts tmc -INNER JOIN tickets t ON tmc."guild_id" = t."guild_id" AND tmc."ticket_id" = t."id" -WHERE tmc."guild_id" = $1 AND t."open" = false AND t."open_time" > NOW() - $2::interval;` - if err := t.QueryRow(ctx, query, guildId, parsedInterval).Scan(&result.OneTouchCount, &result.TotalClosed); err != nil && err != pgx.ErrNoRows { + if err := t.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan(&result.OneTouchCount, &result.TotalClosed); err != nil && err != pgx.ErrNoRows { e = err } - return } diff --git a/tickets.go b/tickets.go index 33812da..9fa3c63 100644 --- a/tickets.go +++ b/tickets.go @@ -1298,12 +1298,47 @@ func (t *TicketTable) GetTotalTicketCountInterval(ctx context.Context, guildId u return } -func (t *TicketTable) GetTotalTicketCount(ctx context.Context, guildId uint64) (count int, e error) { - query := `SELECT COUNT(*) FROM tickets WHERE "guild_id" = $1;` - if err := t.QueryRow(ctx, query, guildId).Scan(&count); err != nil { - e = err +// GetTotalTicketCount returns the all-time ticket count for a guild. Delegates +// to GetTotalTicketCountFiltered with no panel filter to preserve the existing +// signature for the worker module. +func (t *TicketTable) GetTotalTicketCount(ctx context.Context, guildId uint64) (int, error) { + return t.GetTotalTicketCountFiltered(ctx, guildId, nil) +} + +// GetTotalTicketCountFiltered returns the all-time ticket count, optionally +// restricted to the given panels. +func (t *TicketTable) GetTotalTicketCountFiltered(ctx context.Context, guildId uint64, filter *PanelFilter) (int, error) { + query := `SELECT COUNT(*) FROM tickets t WHERE t.guild_id = $1` + PanelPredicate("t", 2, 3) + + arr, unassigned, err := filter.Args() + if err != nil { + return 0, fmt.Errorf("total ticket count: %w", err) } - return + + var count int + if err := t.QueryRow(ctx, query, guildId, arr, unassigned).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +// CountOpenTickets returns the number of currently open tickets, optionally +// restricted to the given panels. This is a bespoke function rather than an +// extension of TicketQueryOptions, whose HasWhereClause() logic is inverted +// and carries its own panel fields. +func (t *TicketTable) CountOpenTickets(ctx context.Context, guildId uint64, filter *PanelFilter) (int, error) { + query := `SELECT COUNT(*) FROM tickets t WHERE t.guild_id = $1 AND t.open = true` + PanelPredicate("t", 2, 3) + + arr, unassigned, err := filter.Args() + if err != nil { + return 0, fmt.Errorf("count open tickets: %w", err) + } + + var count int + if err := t.QueryRow(ctx, query, guildId, arr, unassigned).Scan(&count); err != nil { + return 0, err + } + return count, nil } func (t *TicketTable) Close(ctx context.Context, ticketId int, guildId uint64) (err error) { @@ -1369,15 +1404,31 @@ SET last_ticket_id = EXCLUDED.last_ticket_id;` return } +// GetTicketsPerDay is the worker-compatible wrapper. Delegates to +// GetTicketsPerDayFiltered with no panel filter. func (t *TicketTable) GetTicketsPerDay(ctx context.Context, guildId uint64, nDays int) ([]CountOnDate, error) { + return t.GetTicketsPerDayFiltered(ctx, guildId, nDays, nil) +} + +// GetTicketsPerDayFiltered returns daily ticket counts over the last nDays. +// The panel predicate goes in the JOIN ON condition, NOT the WHERE, because +// this is a LEFT JOIN against generate_series. Putting it in the WHERE +// silently converts to an inner join and drops zero-count days. +func (t *TicketTable) GetTicketsPerDayFiltered(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]CountOnDate, error) { query := ` SELECT d::date AS date, COUNT(t.id) AS count FROM generate_series(CURRENT_DATE - ($2 - 1) * INTERVAL '1 day', CURRENT_DATE, '1 day') AS d -LEFT JOIN tickets t ON t.guild_id = $1 AND date_trunc('day', t.open_time AT TIME ZONE 'UTC') = d::date +LEFT JOIN tickets t ON t.guild_id = $1 AND date_trunc('day', t.open_time AT TIME ZONE 'UTC') = d::date` + + PanelPredicate("t", 3, 4) + ` GROUP BY d::date ORDER BY d::date;` - rows, err := t.Query(ctx, query, guildId, nDays) + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("tickets per day: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) if err != nil { return nil, err } @@ -1395,15 +1446,21 @@ ORDER BY d::date;` return counts, nil } -func (t *TicketTable) GetTicketsPerWeek(ctx context.Context, guildId uint64, nDays int) ([]CountOnDate, error) { +func (t *TicketTable) GetTicketsPerWeek(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]CountOnDate, error) { query := ` -SELECT date_trunc('week', open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*)::bigint AS count -FROM tickets -WHERE guild_id = $1 AND ($2 = 0 OR open_time > CURRENT_DATE - $2 * INTERVAL '1 day') +SELECT date_trunc('week', t.open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*)::bigint AS count +FROM tickets t +WHERE t.guild_id = $1 AND ($2 = 0 OR t.open_time > CURRENT_DATE - $2 * INTERVAL '1 day')` + + PanelPredicate("t", 3, 4) + ` GROUP BY 1 ORDER BY 1;` - rows, err := t.Query(ctx, query, guildId, nDays) + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("tickets per week: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) if err != nil { return nil, err } @@ -1421,24 +1478,46 @@ ORDER BY 1;` return counts, nil } +// GetTicketDurationTripleWindow is the worker-compatible wrapper. Delegates +// to GetDurationWindows with days=0 and no panel filter. func (t *TicketTable) GetTicketDurationTripleWindow(ctx context.Context, guildId uint64) (TripleWindow, error) { + mw, err := t.GetDurationWindows(ctx, guildId, 0, nil) + if err != nil { + return TripleWindow{}, err + } + return mw.TripleWindow(), nil +} + +// GetDurationWindows returns four resolution-time averages in one round trip: +// the exact selected range, all time, monthly, and weekly. The selected window +// equals all-time when days == 0. +func (t *TicketTable) GetDurationWindows(ctx context.Context, guildId uint64, days int, filter *PanelFilter) (MetricWindows, error) { query := ` SELECT - AVG(EXTRACT(EPOCH FROM (close_time - open_time))) FILTER (WHERE close_time IS NOT NULL), - AVG(EXTRACT(EPOCH FROM (close_time - open_time))) FILTER (WHERE close_time IS NOT NULL AND close_time > NOW() - INTERVAL '30 days'), - AVG(EXTRACT(EPOCH FROM (close_time - open_time))) FILTER (WHERE close_time IS NOT NULL AND close_time > NOW() - INTERVAL '7 days') -FROM tickets -WHERE guild_id = $1;` + AVG(EXTRACT(EPOCH FROM (t.close_time - t.open_time))) FILTER (WHERE t.close_time IS NOT NULL AND ($2 = 0 OR t.close_time > NOW() - make_interval(days => $2))), + AVG(EXTRACT(EPOCH FROM (t.close_time - t.open_time))) FILTER (WHERE t.close_time IS NOT NULL), + AVG(EXTRACT(EPOCH FROM (t.close_time - t.open_time))) FILTER (WHERE t.close_time IS NOT NULL AND t.close_time > NOW() - INTERVAL '30 days'), + AVG(EXTRACT(EPOCH FROM (t.close_time - t.open_time))) FILTER (WHERE t.close_time IS NOT NULL AND t.close_time > NOW() - INTERVAL '7 days') +FROM tickets t +WHERE t.guild_id = $1` + PanelPredicate("t", 3, 4) - var allTimeSecs, monthlySecs, weeklySecs *float64 - if err := t.QueryRow(ctx, query, guildId).Scan(&allTimeSecs, &monthlySecs, &weeklySecs); err != nil { - return TripleWindow{}, err + arr, unassigned, err := filter.Args() + if err != nil { + return MetricWindows{}, fmt.Errorf("duration windows: %w", err) } - return TripleWindow{ - AllTime: secondsToDuration(allTimeSecs), - Monthly: secondsToDuration(monthlySecs), - Weekly: secondsToDuration(weeklySecs), + var selectedSecs, allTimeSecs, monthlySecs, weeklySecs *float64 + if err := t.QueryRow(ctx, query, guildId, days, arr, unassigned).Scan( + &selectedSecs, &allTimeSecs, &monthlySecs, &weeklySecs, + ); err != nil { + return MetricWindows{}, err + } + + return MetricWindows{ + Selected: secondsToDuration(selectedSecs), + AllTime: secondsToDuration(allTimeSecs), + Monthly: secondsToDuration(monthlySecs), + Weekly: secondsToDuration(weeklySecs), }, nil } @@ -1450,16 +1529,22 @@ func secondsToDuration(secs *float64) *time.Duration { return &d } -func (t *TicketTable) GetTicketCountByPanel(ctx context.Context, guildId uint64, nDays int) ([]PanelTicketCount, error) { +func (t *TicketTable) GetTicketCountByPanel(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]PanelTicketCount, error) { query := ` SELECT t.panel_id, COALESCE(p.title, 'No Panel'), COUNT(*) FROM tickets t LEFT JOIN panels p ON t.panel_id = p.panel_id -WHERE t.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day' +WHERE t.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + ` GROUP BY t.panel_id, p.title ORDER BY COUNT(*) DESC;` - rows, err := t.Query(ctx, query, guildId, nDays) + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("ticket count by panel: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) if err != nil { return nil, err } @@ -1477,68 +1562,53 @@ ORDER BY COUNT(*) DESC;` return results, nil } +// GetThreadChannelSplit is the worker-compatible wrapper. Delegates to +// GetThreadChannelSplitFiltered with no panel filter. func (t *TicketTable) GetThreadChannelSplit(ctx context.Context, guildId uint64, nDays int) (ThreadChannelSplit, error) { + return t.GetThreadChannelSplitFiltered(ctx, guildId, nDays, nil) +} + +func (t *TicketTable) GetThreadChannelSplitFiltered(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) (ThreadChannelSplit, error) { query := ` SELECT - COUNT(*) FILTER (WHERE is_thread = true), - COUNT(*) FILTER (WHERE is_thread = false) -FROM tickets -WHERE guild_id = $1 AND open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day';` + COUNT(*) FILTER (WHERE t.is_thread = true), + COUNT(*) FILTER (WHERE t.is_thread = false) +FROM tickets t +WHERE t.guild_id = $1 AND t.open_time > CURRENT_DATE - ($2 - 1) * INTERVAL '1 day'` + + PanelPredicate("t", 3, 4) + + arr, unassigned, err := filter.Args() + if err != nil { + return ThreadChannelSplit{}, fmt.Errorf("thread channel split: %w", err) + } var split ThreadChannelSplit - if err := t.QueryRow(ctx, query, guildId, nDays).Scan(&split.ThreadCount, &split.ChannelCount); err != nil { + if err := t.QueryRow(ctx, query, guildId, nDays, arr, unassigned).Scan(&split.ThreadCount, &split.ChannelCount); err != nil { return ThreadChannelSplit{}, err } return split, nil } -func (t *TicketTable) GetPeakHours(ctx context.Context, guildId uint64, days int) ([]PeakHourEntry, error) { - if days == 0 { - query := ` +func (t *TicketTable) GetPeakHours(ctx context.Context, guildId uint64, days int, filter *PanelFilter) ([]PeakHourEntry, error) { + query := ` SELECT - EXTRACT(DOW FROM open_time AT TIME ZONE 'UTC')::int AS day_of_week, - EXTRACT(HOUR FROM open_time AT TIME ZONE 'UTC')::int AS hour_of_day, + EXTRACT(DOW FROM t.open_time AT TIME ZONE 'UTC')::int AS day_of_week, + EXTRACT(HOUR FROM t.open_time AT TIME ZONE 'UTC')::int AS hour_of_day, COUNT(*)::int AS count -FROM tickets -WHERE guild_id = $1 +FROM tickets t +WHERE t.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + ` GROUP BY day_of_week, hour_of_day ORDER BY day_of_week, hour_of_day;` - rows, err := t.Query(ctx, query, guildId) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []PeakHourEntry - for rows.Next() { - var r PeakHourEntry - if err := rows.Scan(&r.DayOfWeek, &r.HourOfDay, &r.Count); err != nil { - return nil, err - } - results = append(results, r) - } - - return results, nil - } - - parsedInterval := pgtype.Interval{} - if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { - return nil, err + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("peak hours: %w", err) } - query := ` -SELECT - EXTRACT(DOW FROM open_time AT TIME ZONE 'UTC')::int AS day_of_week, - EXTRACT(HOUR FROM open_time AT TIME ZONE 'UTC')::int AS hour_of_day, - COUNT(*)::int AS count -FROM tickets -WHERE guild_id = $1 AND open_time > NOW() - $2::interval -GROUP BY day_of_week, hour_of_day -ORDER BY day_of_week, hour_of_day;` - - rows, err := t.Query(ctx, query, guildId, parsedInterval) + rows, err := t.Query(ctx, query, guildId, days, arr, unassigned) if err != nil { return nil, err } @@ -1556,46 +1626,22 @@ ORDER BY day_of_week, hour_of_day;` return results, nil } -func (t *TicketTable) GetTicketsBySource(ctx context.Context, guildId uint64, days int) ([]SourceBreakdown, error) { - if days == 0 { - query := ` -SELECT "source", COUNT(*)::int AS count -FROM tickets -WHERE guild_id = $1 -GROUP BY "source" +func (t *TicketTable) GetTicketsBySource(ctx context.Context, guildId uint64, days int, filter *PanelFilter) ([]SourceBreakdown, error) { + query := ` +SELECT t."source", COUNT(*)::int AS count +FROM tickets t +WHERE t.guild_id = $1 + AND ($2 = 0 OR t.open_time > NOW() - make_interval(days => $2))` + + PanelPredicate("t", 3, 4) + ` +GROUP BY t."source" ORDER BY count DESC;` - rows, err := t.Query(ctx, query, guildId) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []SourceBreakdown - for rows.Next() { - var r SourceBreakdown - if err := rows.Scan(&r.Source, &r.Count); err != nil { - return nil, err - } - results = append(results, r) - } - - return results, nil - } - - parsedInterval := pgtype.Interval{} - if err := parsedInterval.Set(time.Duration(days) * 24 * time.Hour); err != nil { - return nil, err + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("tickets by source: %w", err) } - query := ` -SELECT "source", COUNT(*)::int AS count -FROM tickets -WHERE guild_id = $1 AND open_time > NOW() - $2::interval -GROUP BY "source" -ORDER BY count DESC;` - - rows, err := t.Query(ctx, query, guildId, parsedInterval) + rows, err := t.Query(ctx, query, guildId, days, arr, unassigned) if err != nil { return nil, err } @@ -1613,20 +1659,24 @@ ORDER BY count DESC;` return results, nil } -func (t *TicketTable) GetBacklogTrend(ctx context.Context, guildId uint64, nDays int) ([]CountOnDate, error) { +func (t *TicketTable) GetBacklogTrend(ctx context.Context, guildId uint64, nDays int, filter *PanelFilter) ([]CountOnDate, error) { + // The same two panel-filter placeholders ($3, $4) are referenced in all + // three CTEs. Args() is appended once; PostgreSQL handles reuse. + panelPred := PanelPredicate("tickets", 3, 4) + query := ` WITH opens AS ( SELECT date_trunc('day', open_time AT TIME ZONE 'UTC')::date AS date, COUNT(*) AS cnt - FROM tickets WHERE guild_id = $1 GROUP BY 1 + FROM tickets WHERE guild_id = $1` + panelPred + ` GROUP BY 1 ), closes AS ( SELECT date_trunc('day', close_time AT TIME ZONE 'UTC')::date AS date, COUNT(*) AS cnt - FROM tickets WHERE guild_id = $1 AND close_time IS NOT NULL GROUP BY 1 + FROM tickets WHERE guild_id = $1 AND close_time IS NOT NULL` + panelPred + ` GROUP BY 1 ), series AS ( SELECT d::date AS date FROM generate_series(CURRENT_DATE - ($2 - 1) * INTERVAL '1 day', CURRENT_DATE, '1 day') d ), base_open AS ( SELECT COUNT(*) AS cnt FROM tickets WHERE guild_id = $1 AND open_time < CURRENT_DATE - ($2 - 1) * INTERVAL '1 day' - AND (close_time IS NULL OR close_time >= CURRENT_DATE - ($2 - 1) * INTERVAL '1 day') + AND (close_time IS NULL OR close_time >= CURRENT_DATE - ($2 - 1) * INTERVAL '1 day')` + panelPred + ` ) SELECT s.date, (SELECT cnt FROM base_open) @@ -1637,7 +1687,12 @@ LEFT JOIN opens o ON o.date = s.date LEFT JOIN closes c ON c.date = s.date ORDER BY s.date;` - rows, err := t.Query(ctx, query, guildId, nDays) + arr, unassigned, err := filter.Args() + if err != nil { + return nil, fmt.Errorf("backlog trend: %w", err) + } + + rows, err := t.Query(ctx, query, guildId, nDays, arr, unassigned) if err != nil { return nil, err } From c82111a219cff05ad2a91b26cb41c8cf95ef29a0 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:40:20 +0200 Subject: [PATCH 39/49] Add case-insensitive close reason aggregation Extend `GetTopCloseReasonsWithCount` with a `caseInsensitive` flag and build the SQL expression dynamically so close reasons can be grouped by `LOWER(close_reason)` when requested. This ensures top-close-reason counts can treat casing variants as the same reason while preserving existing behavior by default. --- closereason.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/closereason.go b/closereason.go index c533192..f29cada 100644 --- a/closereason.go +++ b/closereason.go @@ -157,9 +157,14 @@ type CloseReasonCount struct { Count int `json:"count"` } -func (c *CloseMetadataTable) GetTopCloseReasonsWithCount(ctx context.Context, guildId uint64, panelId *int, limit, days int, filter *PanelFilter) ([]CloseReasonCount, error) { +func (c *CloseMetadataTable) GetTopCloseReasonsWithCount(ctx context.Context, guildId uint64, panelId *int, limit, days int, caseInsensitive bool, filter *PanelFilter) ([]CloseReasonCount, error) { + reasonExpr := "cr.close_reason" + if caseInsensitive { + reasonExpr = "LOWER(cr.close_reason)" + } + query := ` -SELECT cr.close_reason, COUNT(*)::int AS count +SELECT ` + reasonExpr + ` AS close_reason, COUNT(*)::int AS count FROM close_reason cr INNER JOIN tickets t ON cr.guild_id = t.guild_id AND cr.ticket_id = t.id WHERE cr.guild_id = $1 @@ -169,7 +174,7 @@ WHERE cr.guild_id = $1 AND cr.close_reason != 'Automatically closed due to inactivity' AND ($4 = 0 OR t.open_time > NOW() - make_interval(days => $4))` + PanelPredicate("t", 5, 6) + ` -GROUP BY cr.close_reason +GROUP BY ` + reasonExpr + ` ORDER BY COUNT(*) DESC LIMIT $3;` From 5c547e0f74c97bc70f35d6057442a05691219d42 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:48:35 +0200 Subject: [PATCH 40/49] Add KB article helpful vote counters Extend KB articles with `show_helpful_count`, `helpful_count`, and `not_helpful_count` fields in the model and schema (including ALTER TABLE backfill for existing installs). Wire the new columns into scans and column lists, persist `show_helpful_count` in create/update queries, and add an atomic `IncrementFeedback` update to safely record anonymous web votes without exposing counts to editor overwrites. --- kbarticle.go | 65 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/kbarticle.go b/kbarticle.go index 8893cf2..31ebca6 100644 --- a/kbarticle.go +++ b/kbarticle.go @@ -10,19 +10,22 @@ import ( ) type KBArticle struct { - Id int `json:"id"` - GuildId uint64 `json:"guild_id,string"` - Title string `json:"title"` - Slug string `json:"slug"` - Description *string `json:"description"` - Content *string `json:"content"` - Embed *CustomEmbedWithFields `json:"embed"` - CategoryIds []int `json:"category_ids"` - Keywords []string `json:"keywords"` - Position int `json:"position"` - Published bool `json:"published"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id int `json:"id"` + GuildId uint64 `json:"guild_id,string"` + Title string `json:"title"` + Slug string `json:"slug"` + Description *string `json:"description"` + Content *string `json:"content"` + Embed *CustomEmbedWithFields `json:"embed"` + CategoryIds []int `json:"category_ids"` + Keywords []string `json:"keywords"` + Position int `json:"position"` + Published bool `json:"published"` + ShowHelpfulCount bool `json:"show_helpful_count"` + HelpfulCount int `json:"helpful_count"` + NotHelpfulCount int `json:"not_helpful_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type KBArticlesTable struct { @@ -49,6 +52,9 @@ CREATE TABLE IF NOT EXISTS kb_articles( "keywords" text[] DEFAULT '{}', "position" int4 NOT NULL DEFAULT 0, "published" bool NOT NULL DEFAULT true, + "show_helpful_count" bool NOT NULL DEFAULT false, + "helpful_count" int4 NOT NULL DEFAULT 0, + "not_helpful_count" int4 NOT NULL DEFAULT 0, "search_vector" tsvector, "created_at" timestamptz NOT NULL DEFAULT NOW(), "updated_at" timestamptz NOT NULL DEFAULT NOW(), @@ -70,6 +76,10 @@ BEGIN END; $$ LANGUAGE plpgsql; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS "show_helpful_count" bool NOT NULL DEFAULT false; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS "helpful_count" int4 NOT NULL DEFAULT 0; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS "not_helpful_count" int4 NOT NULL DEFAULT 0; + DROP TRIGGER IF EXISTS kb_articles_search_vector_trigger ON kb_articles; CREATE TRIGGER kb_articles_search_vector_trigger BEFORE INSERT OR UPDATE ON kb_articles @@ -77,7 +87,7 @@ CREATE TRIGGER kb_articles_search_vector_trigger ` } -var kbArticleColumns = `"id", "guild_id", "title", "slug", "description", "content", "embed", "category_ids", "keywords", "position", "published", "created_at", "updated_at"` +var kbArticleColumns = `"id", "guild_id", "title", "slug", "description", "content", "embed", "category_ids", "keywords", "position", "published", "show_helpful_count", "helpful_count", "not_helpful_count", "created_at", "updated_at"` func scanKBArticle(row pgx.Row) (KBArticle, error) { var article KBArticle @@ -97,6 +107,9 @@ func scanKBArticle(row pgx.Row) (KBArticle, error) { &keywords, &article.Position, &article.Published, + &article.ShowHelpfulCount, + &article.HelpfulCount, + &article.NotHelpfulCount, &article.CreatedAt, &article.UpdatedAt, ) @@ -299,8 +312,8 @@ type KBArticleSummary struct { func (t *KBArticlesTable) Create(ctx context.Context, article KBArticle) (int, error) { query := ` -INSERT INTO kb_articles("guild_id", "title", "slug", "description", "content", "embed", "category_ids", "keywords", "position", "published") -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +INSERT INTO kb_articles("guild_id", "title", "slug", "description", "content", "embed", "category_ids", "keywords", "position", "published", "show_helpful_count") +VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING "id"; ` @@ -328,6 +341,7 @@ RETURNING "id"; keywords, article.Position, article.Published, + article.ShowHelpfulCount, ).Scan(&id) return id, err } @@ -335,8 +349,8 @@ RETURNING "id"; func (t *KBArticlesTable) Update(ctx context.Context, article KBArticle) error { query := ` UPDATE kb_articles -SET "title" = $2, "slug" = $3, "description" = $4, "content" = $5, "embed" = $6, "category_ids" = $7, "keywords" = $8, "position" = $9, "published" = $10, "updated_at" = NOW() -WHERE "id" = $1 AND "guild_id" = $11; +SET "title" = $2, "slug" = $3, "description" = $4, "content" = $5, "embed" = $6, "category_ids" = $7, "keywords" = $8, "position" = $9, "published" = $10, "show_helpful_count" = $11, "updated_at" = NOW() +WHERE "id" = $1 AND "guild_id" = $12; ` var embedRaw *string @@ -362,11 +376,26 @@ WHERE "id" = $1 AND "guild_id" = $11; keywords, article.Position, article.Published, + article.ShowHelpfulCount, article.GuildId, ) return err } +// IncrementFeedback records one web vote. Kept out of Update so that saving an +// article cannot overwrite the tally. +func (t *KBArticlesTable) IncrementFeedback(ctx context.Context, guildId uint64, articleId int, helpful bool) error { + query := ` +UPDATE kb_articles +SET "helpful_count" = "helpful_count" + CASE WHEN $3 THEN 1 ELSE 0 END, + "not_helpful_count" = "not_helpful_count" + CASE WHEN $3 THEN 0 ELSE 1 END +WHERE "id" = $2 AND "guild_id" = $1; +` + + _, err := t.Exec(ctx, query, guildId, articleId, helpful) + return err +} + func (t *KBArticlesTable) Delete(ctx context.Context, guildId uint64, articleId int) error { query := `DELETE FROM kb_articles WHERE "guild_id" = $1 AND "id" = $2;` _, err := t.Exec(ctx, query, guildId, articleId) From 1aeb6e82a45aa337e8cf64e877c7dd34669174eb Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:37:13 +0200 Subject: [PATCH 41/49] Add bulk KB article position updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `SetPositions` on `KBArticlesTable` to reorder KB articles in one SQL statement. It no-ops on empty input and updates each article’s `position` for a guild using an `unnest`/`generate_subscripts` mapping from the ordered ID array. --- kbarticle.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/kbarticle.go b/kbarticle.go index 31ebca6..fc4ab05 100644 --- a/kbarticle.go +++ b/kbarticle.go @@ -396,6 +396,22 @@ WHERE "id" = $2 AND "guild_id" = $1; return err } +func (t *KBArticlesTable) SetPositions(ctx context.Context, guildId uint64, ordered []int) error { + if len(ordered) == 0 { + return nil + } + + query := ` +UPDATE kb_articles +SET "position" = data.position, "updated_at" = "updated_at" +FROM (SELECT unnest($2::int[]) AS id, generate_subscripts($2::int[], 1) - 1 AS position) AS data +WHERE kb_articles."id" = data.id AND kb_articles."guild_id" = $1; +` + + _, err := t.Exec(ctx, query, guildId, toInt4Array(ordered)) + return err +} + func (t *KBArticlesTable) Delete(ctx context.Context, guildId uint64, articleId int) error { query := `DELETE FROM kb_articles WHERE "guild_id" = $1 AND "id" = $2;` _, err := t.Exec(ctx, query, guildId, articleId) From 81b78d7c1c59c48b44ce63794be2e795a8e9b072 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:04:47 +0200 Subject: [PATCH 42/49] Add count query for available integrations Introduce `CountAvailableIntegrations` on `CustomIntegrationGuildsTable` to count integrations using the same WHERE logic as `GetAvailableIntegrationsWithActive`. This keeps pagination totals aligned with the listed results by including active guild integrations plus public/approved or owner-accessible ones. --- custom_integration.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/custom_integration.go b/custom_integration.go index 3849f58..982e199 100644 --- a/custom_integration.go +++ b/custom_integration.go @@ -464,6 +464,25 @@ LIMIT $3 OFFSET $4; return integrations, nil } +// Mirrors the WHERE clause of GetAvailableIntegrationsWithActive so page counts match the listing. +func (i *CustomIntegrationGuildsTable) CountAvailableIntegrations(ctx context.Context, guildId, userId uint64) (count int, err error) { + query := ` +WITH active AS ( + SELECT integration_id + FROM custom_integration_guilds + WHERE guild_id=$1 +) +SELECT COUNT(*) +FROM custom_integrations as integrations +LEFT OUTER JOIN active ON active.integration_id = integrations.id +WHERE active.integration_id IS NOT NULL OR + ((integrations.public = 't' AND integrations.approved = 't') OR integrations.owner_id = $2); +` + + err = i.QueryRow(ctx, query, guildId, userId).Scan(&count) + return +} + func (i *CustomIntegrationGuildsTable) CanActivate(ctx context.Context, integrationId int, userId uint64) (canActivate bool, err error) { query := ` SELECT EXISTS( From 487d0d5da3b9b0faaf113e4f94132eec8d1f5deb Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 13 Aug 2026 21:41:16 +0100 Subject: [PATCH 43/49] rm kb custom domains Signed-off-by: Ben --- kbsettings.go | 77 +++++++++++++-------------------------------------- 1 file changed, 19 insertions(+), 58 deletions(-) diff --git a/kbsettings.go b/kbsettings.go index 3694add..6fd0bfc 100644 --- a/kbsettings.go +++ b/kbsettings.go @@ -8,15 +8,13 @@ import ( ) type KBSettings struct { - GuildId uint64 `json:"guild_id,string"` - PrimaryBg *int `json:"primary_bg"` - CardBg *int `json:"card_bg"` - TextColour *int `json:"text_colour"` - AccentColour *int `json:"accent_colour"` - LogoUrl *string `json:"logo_url"` - HideBranding bool `json:"hide_branding"` - CustomDomain *string `json:"custom_domain"` - DomainVerified bool `json:"domain_verified"` + GuildId uint64 `json:"guild_id,string"` + PrimaryBg *int `json:"primary_bg"` + CardBg *int `json:"card_bg"` + TextColour *int `json:"text_colour"` + AccentColour *int `json:"accent_colour"` + LogoUrl *string `json:"logo_url"` + HideBranding bool `json:"hide_branding"` } type KBSettingsTable struct { @@ -29,6 +27,13 @@ func newKBSettings(db *pgxpool.Pool) *KBSettingsTable { } } +// Schema no longer creates the custom domain columns. Databases that already ran +// an earlier version still have custom_domain, domain_verified and the +// domain_status family, plus their indexes; nothing reads or writes them. +// +// They are deliberately not dropped here. DROP COLUMN is irreversible and this +// file runs on every service boot, so removing them is a decision to take +// explicitly rather than a side effect of deploying. func (t KBSettingsTable) Schema() string { return ` CREATE TABLE IF NOT EXISTS kb_settings( @@ -38,17 +43,14 @@ CREATE TABLE IF NOT EXISTS kb_settings( "text_colour" int4 DEFAULT NULL, "accent_colour" int4 DEFAULT NULL, "logo_url" text DEFAULT NULL, - "hide_branding" bool NOT NULL DEFAULT false, - "custom_domain" text DEFAULT NULL, - "domain_verified" bool NOT NULL DEFAULT false + "hide_branding" bool NOT NULL DEFAULT false ); -CREATE INDEX IF NOT EXISTS kb_settings_custom_domain_idx ON kb_settings("custom_domain") WHERE "custom_domain" IS NOT NULL AND "domain_verified" = true; ` } func (t *KBSettingsTable) Get(ctx context.Context, guildId uint64) (KBSettings, bool, error) { query := ` -SELECT "guild_id", "primary_bg", "card_bg", "text_colour", "accent_colour", "logo_url", "hide_branding", "custom_domain", "domain_verified" +SELECT "guild_id", "primary_bg", "card_bg", "text_colour", "accent_colour", "logo_url", "hide_branding" FROM kb_settings WHERE "guild_id" = $1; ` @@ -62,8 +64,6 @@ WHERE "guild_id" = $1; &settings.AccentColour, &settings.LogoUrl, &settings.HideBranding, - &settings.CustomDomain, - &settings.DomainVerified, ) if err != nil { @@ -78,17 +78,15 @@ WHERE "guild_id" = $1; func (t *KBSettingsTable) Set(ctx context.Context, settings KBSettings) error { query := ` -INSERT INTO kb_settings("guild_id", "primary_bg", "card_bg", "text_colour", "accent_colour", "logo_url", "hide_branding", "custom_domain", "domain_verified") -VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) +INSERT INTO kb_settings("guild_id", "primary_bg", "card_bg", "text_colour", "accent_colour", "logo_url", "hide_branding") +VALUES($1, $2, $3, $4, $5, $6, $7) ON CONFLICT("guild_id") DO UPDATE SET "primary_bg" = $2, "card_bg" = $3, "text_colour" = $4, "accent_colour" = $5, "logo_url" = $6, - "hide_branding" = $7, - "custom_domain" = $8, - "domain_verified" = $9; + "hide_branding" = $7; ` _, err := t.Exec(ctx, query, @@ -99,43 +97,6 @@ ON CONFLICT("guild_id") DO UPDATE SET settings.AccentColour, settings.LogoUrl, settings.HideBranding, - settings.CustomDomain, - settings.DomainVerified, ) return err } - -// GetGuildByDomain returns the guild ID that has claimed a given custom domain (verified or not). -// Returns 0, false if no guild has this domain. -func (t *KBSettingsTable) GetGuildByDomain(ctx context.Context, domain string) (uint64, bool, error) { - query := `SELECT "guild_id" FROM kb_settings WHERE "custom_domain" = $1 LIMIT 1;` - var guildId uint64 - err := t.QueryRow(ctx, query, domain).Scan(&guildId) - if err != nil { - if err == pgx.ErrNoRows { - return 0, false, nil - } - return 0, false, err - } - return guildId, true, nil -} - -// GetAllVerifiedDomains returns all verified custom KB domains. -func (t *KBSettingsTable) GetAllVerifiedDomains(ctx context.Context) ([]string, error) { - query := `SELECT "custom_domain" FROM kb_settings WHERE "custom_domain" IS NOT NULL AND "domain_verified" = true;` - rows, err := t.Query(ctx, query) - if err != nil { - return nil, err - } - defer rows.Close() - - var domains []string - for rows.Next() { - var domain string - if err := rows.Scan(&domain); err != nil { - return nil, err - } - domains = append(domains, domain) - } - return domains, nil -} From 4d1d7122f108b7f40c0ef99c05670afbc8bcff69 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:58:21 +0200 Subject: [PATCH 44/49] Add body_template to API config storage Extends `FormInputApiConfig` persistence to support a new optional `body_template` field end-to-end: schema migration, struct field, select/scan paths, and create/update (including Tx variants). Also refactors duplicated row-scanning into a shared helper, introduces a shared column list constant, adds `GetTx`, and reuses `Get` in `GetByFormInputId` for consistency. --- forminputapiconfig.go | 115 ++++++++++++++++++------------------------ 1 file changed, 48 insertions(+), 67 deletions(-) diff --git a/forminputapiconfig.go b/forminputapiconfig.go index 7c09fcc..d9440b4 100644 --- a/forminputapiconfig.go +++ b/forminputapiconfig.go @@ -16,6 +16,7 @@ type FormInputApiConfig struct { Method string `json:"method"` CacheDurationSeconds *int `json:"cache_duration_seconds,omitempty"` NoOptionsMessage *string `json:"no_options_message,omitempty"` + BodyTemplate *string `json:"body_template,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -47,22 +48,22 @@ func (f FormInputApiConfigTable) Schema() string { PRIMARY KEY("id") ); CREATE INDEX IF NOT EXISTS form_input_api_config_form_input_id ON form_input_api_config("form_input_id"); + + ALTER TABLE form_input_api_config ADD COLUMN IF NOT EXISTS "body_template" TEXT DEFAULT NULL; ` } -func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (config FormInputApiConfig, ok bool, e error) { - query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "created_at", "updated_at" - FROM form_input_api_config - WHERE "form_input_id" = $1;` +const formInputApiConfigColumns = `"id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "body_template", "created_at", "updated_at"` - err := f.QueryRow(ctx, query, formInputId).Scan( +func (f *FormInputApiConfigTable) scanOne(row pgx.Row) (config FormInputApiConfig, ok bool, e error) { + err := row.Scan( &config.Id, &config.FormInputId, &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, &config.NoOptionsMessage, + &config.BodyTemplate, &config.CreatedAt, &config.UpdatedAt, ) @@ -78,37 +79,36 @@ func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (con return config, true, nil } -func (f *FormInputApiConfigTable) GetById(ctx context.Context, id int) (config FormInputApiConfig, ok bool, e error) { +func (f *FormInputApiConfigTable) Get(ctx context.Context, formInputId int) (config FormInputApiConfig, ok bool, e error) { query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "created_at", "updated_at" + SELECT ` + formInputApiConfigColumns + ` FROM form_input_api_config - WHERE "id" = $1;` + WHERE "form_input_id" = $1;` - err := f.QueryRow(ctx, query, id).Scan( - &config.Id, - &config.FormInputId, - &config.EndpointUrl, - &config.Method, - &config.CacheDurationSeconds, - &config.NoOptionsMessage, - &config.CreatedAt, - &config.UpdatedAt, - ) + return f.scanOne(f.QueryRow(ctx, query, formInputId)) +} - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return FormInputApiConfig{}, false, nil - } else { - return FormInputApiConfig{}, false, err - } - } +func (f *FormInputApiConfigTable) GetTx(ctx context.Context, tx pgx.Tx, formInputId int) (config FormInputApiConfig, ok bool, e error) { + query := ` + SELECT ` + formInputApiConfigColumns + ` + FROM form_input_api_config + WHERE "form_input_id" = $1;` - return config, true, nil + return f.scanOne(tx.QueryRow(ctx, query, formInputId)) +} + +func (f *FormInputApiConfigTable) GetById(ctx context.Context, id int) (config FormInputApiConfig, ok bool, e error) { + query := ` + SELECT ` + formInputApiConfigColumns + ` + FROM form_input_api_config + WHERE "id" = $1;` + + return f.scanOne(f.QueryRow(ctx, query, id)) } func (f *FormInputApiConfigTable) GetByFormId(ctx context.Context, formId int) ([]FormInputApiConfig, error) { query := ` - SELECT c."id", c."form_input_id", c."endpoint_url", c."method", c."cache_duration_seconds", c."created_at", c."updated_at" + SELECT c."id", c."form_input_id", c."endpoint_url", c."method", c."cache_duration_seconds", c."no_options_message", c."body_template", c."created_at", c."updated_at" FROM form_input_api_config c INNER JOIN form_input i ON c."form_input_id" = i."id" WHERE i."form_id" = $1 @@ -129,6 +129,8 @@ func (f *FormInputApiConfigTable) GetByFormId(ctx context.Context, formId int) ( &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, + &config.NoOptionsMessage, + &config.BodyTemplate, &config.CreatedAt, &config.UpdatedAt, ); err != nil { @@ -141,37 +143,12 @@ func (f *FormInputApiConfigTable) GetByFormId(ctx context.Context, formId int) ( } func (f *FormInputApiConfigTable) GetByFormInputId(ctx context.Context, formInputId int) (config FormInputApiConfig, ok bool, e error) { - query := ` - SELECT "id", "form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "created_at", "updated_at" - FROM form_input_api_config - WHERE "form_input_id" = $1;` - - err := f.QueryRow(ctx, query, formInputId).Scan( - &config.Id, - &config.FormInputId, - &config.EndpointUrl, - &config.Method, - &config.CacheDurationSeconds, - &config.NoOptionsMessage, - &config.CreatedAt, - &config.UpdatedAt, - ) - - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return FormInputApiConfig{}, false, nil - } else { - return FormInputApiConfig{}, false, err - } - } - - return config, true, nil + return f.Get(ctx, formInputId) } func (f *FormInputApiConfigTable) GetAllByGuild(ctx context.Context, guildId uint64) (map[int]FormInputApiConfig, error) { - query := ` - SELECT c."id", c."form_input_id", c."endpoint_url", c."method", c."cache_duration_seconds", c."created_at", c."updated_at" + SELECT c."id", c."form_input_id", c."endpoint_url", c."method", c."cache_duration_seconds", c."no_options_message", c."body_template", c."created_at", c."updated_at" FROM form_input_api_config c INNER JOIN form_input i ON c."form_input_id" = i."id" INNER JOIN forms f ON i."form_id" = f."form_id" @@ -192,6 +169,8 @@ func (f *FormInputApiConfigTable) GetAllByGuild(ctx context.Context, guildId uin &config.EndpointUrl, &config.Method, &config.CacheDurationSeconds, + &config.NoOptionsMessage, + &config.BodyTemplate, &config.CreatedAt, &config.UpdatedAt, ); err != nil { @@ -203,59 +182,61 @@ func (f *FormInputApiConfigTable) GetAllByGuild(ctx context.Context, guildId uin return configs, rows.Err() } -func (f *FormInputApiConfigTable) Create(ctx context.Context, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string) (int, error) { +func (f *FormInputApiConfigTable) Create(ctx context.Context, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string, bodyTemplate *string) (int, error) { query := ` - INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message") - VALUES($1, $2, $3, $4, $5) + INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "body_template") + VALUES($1, $2, $3, $4, $5, $6) RETURNING "id";` var id int - if err := f.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds, noOptionsMessage).Scan(&id); err != nil { + if err := f.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds, noOptionsMessage, bodyTemplate).Scan(&id); err != nil { return 0, err } return id, nil } -func (f *FormInputApiConfigTable) CreateTx(ctx context.Context, tx pgx.Tx, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string) (int, error) { +func (f *FormInputApiConfigTable) CreateTx(ctx context.Context, tx pgx.Tx, formInputId int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string, bodyTemplate *string) (int, error) { query := ` - INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message") - VALUES($1, $2, $3, $4, $5) + INSERT INTO form_input_api_config("form_input_id", "endpoint_url", "method", "cache_duration_seconds", "no_options_message", "body_template") + VALUES($1, $2, $3, $4, $5, $6) RETURNING "id";` var id int - if err := tx.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds, noOptionsMessage).Scan(&id); err != nil { + if err := tx.QueryRow(ctx, query, formInputId, endpointUrl, method, cacheDurationSeconds, noOptionsMessage, bodyTemplate).Scan(&id); err != nil { return 0, err } return id, nil } -func (f *FormInputApiConfigTable) Update(ctx context.Context, id int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string) error { +func (f *FormInputApiConfigTable) Update(ctx context.Context, id int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string, bodyTemplate *string) error { query := ` UPDATE form_input_api_config SET "endpoint_url" = $2, "method" = $3, "cache_duration_seconds" = $4, "no_options_message" = $5, + "body_template" = $6, "updated_at" = CURRENT_TIMESTAMP WHERE "id" = $1;` - _, err := f.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds, noOptionsMessage) + _, err := f.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds, noOptionsMessage, bodyTemplate) return err } -func (f *FormInputApiConfigTable) UpdateTx(ctx context.Context, tx pgx.Tx, id int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string) error { +func (f *FormInputApiConfigTable) UpdateTx(ctx context.Context, tx pgx.Tx, id int, endpointUrl string, method string, cacheDurationSeconds *int, noOptionsMessage *string, bodyTemplate *string) error { query := ` UPDATE form_input_api_config SET "endpoint_url" = $2, "method" = $3, "cache_duration_seconds" = $4, "no_options_message" = $5, + "body_template" = $6, "updated_at" = CURRENT_TIMESTAMP WHERE "id" = $1;` - _, err := tx.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds, noOptionsMessage) + _, err := tx.Exec(ctx, query, id, endpointUrl, method, cacheDurationSeconds, noOptionsMessage, bodyTemplate) return err } From c19fa058824772125f50c6b5e9478e53a5852faf Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 17 Aug 2026 17:48:44 +0100 Subject: [PATCH 45/49] audit log Signed-off-by: Ben --- auditlog.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/auditlog.go b/auditlog.go index d165aa1..83f0239 100644 --- a/auditlog.go +++ b/auditlog.go @@ -153,6 +153,10 @@ const ( AuditActionFeatureFlagToggle AuditActionType = 440 AuditActionFeatureFlagCreate AuditActionType = 441 AuditActionFeatureFlagRulesUpdate AuditActionType = 442 + + AuditActionTicketContentView AuditActionType = 450 + AuditActionTranscriptContentView AuditActionType = 451 + AuditActionTicketContentSendBlock AuditActionType = 452 ) type AuditResourceType int16 From c9762dc17a119b04a5d3c93aa2ddc211247c0c5f Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:04:25 +0200 Subject: [PATCH 46/49] Add admin global_view support to bot staff Extends bot staff persistence with a new `global_view` flag (schema migration, model field, and read/write methods), restricted to admin-tier users in SQL. Tier upserts/updates now automatically clear `global_view` when a user is not admin, and audit action constants are updated to include `AuditActionBotStaffGlobalViewUpdate` while removing unused ticket/transcript content view action IDs. --- auditlog.go | 11 ++++------- botstaff.go | 47 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/auditlog.go b/auditlog.go index 83f0239..86ba39a 100644 --- a/auditlog.go +++ b/auditlog.go @@ -90,9 +90,10 @@ const ( AuditActionWhitelabelStatusDelete AuditActionType = 214 AuditActionWhitelabelResync AuditActionType = 215 - AuditActionBotStaffAdd AuditActionType = 300 - AuditActionBotStaffRemove AuditActionType = 301 - AuditActionBotStaffTierUpdate AuditActionType = 302 + AuditActionBotStaffAdd AuditActionType = 300 + AuditActionBotStaffRemove AuditActionType = 301 + AuditActionBotStaffTierUpdate AuditActionType = 302 + AuditActionBotStaffGlobalViewUpdate AuditActionType = 303 AuditActionGlobalBlacklistAdd AuditActionType = 310 AuditActionGlobalBlacklistRemove AuditActionType = 311 @@ -153,10 +154,6 @@ const ( AuditActionFeatureFlagToggle AuditActionType = 440 AuditActionFeatureFlagCreate AuditActionType = 441 AuditActionFeatureFlagRulesUpdate AuditActionType = 442 - - AuditActionTicketContentView AuditActionType = 450 - AuditActionTranscriptContentView AuditActionType = 451 - AuditActionTicketContentSendBlock AuditActionType = 452 ) type AuditResourceType int16 diff --git a/botstaff.go b/botstaff.go index ce24eb3..a02aebe 100644 --- a/botstaff.go +++ b/botstaff.go @@ -15,8 +15,9 @@ const ( ) type BotStaffEntry struct { - UserId uint64 `json:"id,string"` - Tier BotStaffTier `json:"tier"` + UserId uint64 `json:"id,string"` + Tier BotStaffTier `json:"tier"` + GlobalView bool `json:"global_view"` } type BotStaff struct { @@ -35,7 +36,9 @@ CREATE TABLE IF NOT EXISTS bot_staff( "user_id" int8 NOT NULL UNIQUE, "tier" TEXT NOT NULL DEFAULT 'helper', PRIMARY KEY("user_id") -);` +); + +ALTER TABLE bot_staff ADD COLUMN IF NOT EXISTS "global_view" bool NOT NULL DEFAULT false;` } func (s *BotStaff) IsStaff(ctx context.Context, userId uint64) (isStaff bool, err error) { @@ -63,8 +66,35 @@ func (s *BotStaff) GetTier(ctx context.Context, userId uint64) (BotStaffTier, er return tier, err } +// Tier is in the query so no caller can report true for a helper row. +func (s *BotStaff) HasGlobalView(ctx context.Context, userId uint64) (bool, error) { + query := `SELECT "global_view" FROM bot_staff WHERE "user_id" = $1 AND "tier" = 'admin'` + + var globalView bool + err := s.QueryRow(ctx, query, userId).Scan(&globalView) + if err == pgx.ErrNoRows { + return false, nil + } + + return globalView, err +} + +func (s *BotStaff) SetGlobalView(ctx context.Context, userId uint64, enabled bool) (bool, error) { + query := ` +UPDATE bot_staff +SET "global_view" = $2 +WHERE "user_id" = $1 AND "tier" = 'admin';` + + tag, err := s.Exec(ctx, query, userId, enabled) + if err != nil { + return false, err + } + + return tag.RowsAffected() > 0, nil +} + func (s *BotStaff) GetAll(ctx context.Context) ([]BotStaffEntry, error) { - query := `SELECT "user_id", "tier" FROM bot_staff ORDER BY "tier", "user_id";` + query := `SELECT "user_id", "tier", "global_view" FROM bot_staff ORDER BY "tier", "user_id";` rows, err := s.Query(ctx, query) if err != nil { @@ -76,7 +106,7 @@ func (s *BotStaff) GetAll(ctx context.Context) ([]BotStaffEntry, error) { var entries []BotStaffEntry for rows.Next() { var entry BotStaffEntry - if err = rows.Scan(&entry.UserId, &entry.Tier); err != nil { + if err = rows.Scan(&entry.UserId, &entry.Tier, &entry.GlobalView); err != nil { return nil, err } @@ -90,7 +120,9 @@ func (s *BotStaff) Add(ctx context.Context, userId uint64, tier BotStaffTier) (e query := ` INSERT INTO bot_staff("user_id", "tier") VALUES($1, $2) -ON CONFLICT("user_id") DO UPDATE SET "tier" = $2; +ON CONFLICT("user_id") DO UPDATE +SET "tier" = $2, + "global_view" = (bot_staff."global_view" AND $2::text = 'admin'); ` _, err = s.Exec(ctx, query, userId, tier) @@ -100,7 +132,8 @@ ON CONFLICT("user_id") DO UPDATE SET "tier" = $2; func (s *BotStaff) UpdateTier(ctx context.Context, userId uint64, tier BotStaffTier) (err error) { query := ` UPDATE bot_staff -SET "tier" = $2 +SET "tier" = $2, + "global_view" = ("global_view" AND $2::text = 'admin') WHERE "user_id" = $1;` _, err = s.Exec(ctx, query, userId, tier) From f146495a7c1991f3e2eadf43adfe4ac3ffda16cd Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:32:02 +0200 Subject: [PATCH 47/49] Add staff/global filters to audit log queries Introduces a canonical `StaffAuditActionTypes` list for admin API actions and extends `AuditLogQueryOptions` with `GlobalOnly` and `ActionTypes`. Query building now supports `guild_id IS NULL` filtering when requested and `action_type = ANY(...)` filtering that combines with existing `ActionType` conditions instead of replacing them. --- auditlog.go | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/auditlog.go b/auditlog.go index 86ba39a..176defd 100644 --- a/auditlog.go +++ b/auditlog.go @@ -156,6 +156,14 @@ const ( AuditActionFeatureFlagRulesUpdate AuditActionType = 442 ) +type AuditCategory int16 + +const ( + AuditCategoryGuild AuditCategory = 1 + AuditCategoryUser AuditCategory = 2 + AuditCategoryStaff AuditCategory = 3 +) + type AuditResourceType int16 const ( @@ -201,6 +209,7 @@ const ( type AuditLogEntry struct { Id int64 + Category AuditCategory GuildId *uint64 UserId uint64 ActionType AuditActionType @@ -213,6 +222,7 @@ type AuditLogEntry struct { } type AuditLogQueryOptions struct { + Category *AuditCategory GuildId *uint64 UserId *uint64 ActionType *int16 @@ -237,6 +247,7 @@ func (t AuditLogTable) Schema() string { return ` CREATE TABLE IF NOT EXISTS audit_logs ( "id" BIGSERIAL PRIMARY KEY, + "category" INT2 NOT NULL, "guild_id" INT8 DEFAULT NULL, "user_id" INT8 NOT NULL, "action_type" INT2 NOT NULL, @@ -247,6 +258,8 @@ CREATE TABLE IF NOT EXISTS audit_logs ( "metadata" JSONB DEFAULT NULL, "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS "category" INT2; +CREATE INDEX IF NOT EXISTS audit_logs_category_created_at_idx ON audit_logs("category", "created_at" DESC); CREATE INDEX IF NOT EXISTS audit_logs_guild_id_created_at_idx ON audit_logs("guild_id", "created_at" DESC); CREATE INDEX IF NOT EXISTS audit_logs_user_id_idx ON audit_logs("user_id"); CREATE INDEX IF NOT EXISTS audit_logs_action_type_idx ON audit_logs("action_type"); @@ -257,10 +270,11 @@ CREATE INDEX IF NOT EXISTS audit_logs_created_at_idx ON audit_logs("created_at" func (t *AuditLogTable) Insert(ctx context.Context, entry AuditLogEntry) error { query := ` -INSERT INTO audit_logs ("guild_id", "user_id", "action_type", "resource_type", "resource_id", "old_data", "new_data", "metadata") -VALUES ($1, $2, $3, $4, $5, $6, $7, $8);` +INSERT INTO audit_logs ("category", "guild_id", "user_id", "action_type", "resource_type", "resource_id", "old_data", "new_data", "metadata") +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);` _, err := t.Exec(ctx, query, + entry.Category, entry.GuildId, entry.UserId, entry.ActionType, @@ -274,7 +288,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8);` } func (t *AuditLogTable) Query(ctx context.Context, opts AuditLogQueryOptions) ([]AuditLogEntry, error) { - query, args := buildAuditLogQuery("SELECT \"id\", \"guild_id\", \"user_id\", \"action_type\", \"resource_type\", \"resource_id\", \"old_data\", \"new_data\", \"metadata\", \"created_at\" FROM audit_logs", opts) + query, args := buildAuditLogQuery("SELECT \"id\", COALESCE(\"category\", 0), \"guild_id\", \"user_id\", \"action_type\", \"resource_type\", \"resource_id\", \"old_data\", \"new_data\", \"metadata\", \"created_at\" FROM audit_logs", opts) query += " ORDER BY \"created_at\" DESC" if opts.Limit > 0 { @@ -298,6 +312,7 @@ func (t *AuditLogTable) Query(ctx context.Context, opts AuditLogQueryOptions) ([ var entry AuditLogEntry if err := rows.Scan( &entry.Id, + &entry.Category, &entry.GuildId, &entry.UserId, &entry.ActionType, @@ -333,6 +348,11 @@ func buildAuditLogQuery(base string, opts AuditLogQueryOptions) (string, []inter var conditions []string var args []interface{} + if opts.Category != nil { + args = append(args, *opts.Category) + conditions = append(conditions, fmt.Sprintf("\"category\" = $%d", len(args))) + } + if opts.GuildId != nil { args = append(args, *opts.GuildId) conditions = append(conditions, fmt.Sprintf("\"guild_id\" = $%d", len(args))) From 612e01f95cd21c79196b2538e1d4ed3a2e23d6d3 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:31:29 +0200 Subject: [PATCH 48/49] Add ticket sorting by rating and close reason Introduces a `SortBy` option in `TicketQueryOptions` with allowed values for rating and close reason. Query building now conditionally joins `service_ratings` and `close_reason` when sorting by those fields, and switches these joins to `LEFT JOIN` so tickets without related rows are still returned. `ORDER BY` now supports the selected sort field with `NULLS LAST` and a ticket ID tie-breaker for stable pagination, while preserving safe SQL by restricting sortable values to constants. --- tickets.go | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/tickets.go b/tickets.go index 9fa3c63..ba258d7 100644 --- a/tickets.go +++ b/tickets.go @@ -43,6 +43,7 @@ type TicketQueryOptions struct { Rating int `json:"rating"` LabelIds []int `json:"label_ids"` CloseReasonSearch string `json:"close_reason_search"` + SortBy SortBy `json:"sort_by"` Order OrderType `json:"order_type"` Limit int `json:"limit"` Offset int `json:"offset"` @@ -56,6 +57,14 @@ const ( OrderTypeDescending OrderType = "DESC" ) +type SortBy string + +const ( + SortByTicketId SortBy = "" + SortByRating SortBy = "rating" + SortByCloseReason SortBy = "close_reason" +) + func (o TicketQueryOptions) HasWhereClause() bool { return o.Id == 0 && o.GuildId == 0 && @@ -273,12 +282,12 @@ SELECT tickets.id, tickets.status FROM tickets` - if o.Rating != 0 { - query += " INNER JOIN service_ratings ON tickets.guild_id = service_ratings.guild_id AND tickets.id = service_ratings.ticket_id " + if o.Rating != 0 || o.SortBy == SortByRating { + query += " LEFT JOIN service_ratings ON tickets.guild_id = service_ratings.guild_id AND tickets.id = service_ratings.ticket_id " } - if o.ClosedById != 0 || o.CloseReasonSearch != "" { - query += " INNER JOIN close_reason ON tickets.guild_id = close_reason.guild_id AND tickets.id = close_reason.ticket_id " + if o.ClosedById != 0 || o.CloseReasonSearch != "" || o.SortBy == SortByCloseReason { + query += " LEFT JOIN close_reason ON tickets.guild_id = close_reason.guild_id AND tickets.id = close_reason.ticket_id " } if o.ClaimedById != 0 { @@ -410,9 +419,17 @@ FROM tickets` needsAnd = true } - // Cannot use prepared statement for this value + // Cannot use prepared statements for these values; both are compared against constants first. if o.Order == OrderTypeAscending || o.Order == OrderTypeDescending { - query += fmt.Sprintf(` ORDER BY "id" %s `, o.Order) + // tickets.id breaks ties so paging stays stable across the many equal sort values. + switch o.SortBy { + case SortByRating: + query += fmt.Sprintf(` ORDER BY service_ratings.rating %s NULLS LAST, tickets.id DESC `, o.Order) + case SortByCloseReason: + query += fmt.Sprintf(` ORDER BY close_reason.close_reason %s NULLS LAST, tickets.id DESC `, o.Order) + default: + query += fmt.Sprintf(` ORDER BY tickets."id" %s `, o.Order) + } } if o.Limit != 0 { @@ -433,11 +450,11 @@ func (o TicketQueryOptions) BuildCountQuery() (query string, args []interface{}, query = "SELECT COUNT(*) FROM tickets" if o.Rating != 0 { - query += " INNER JOIN service_ratings ON tickets.guild_id = service_ratings.guild_id AND tickets.id = service_ratings.ticket_id " + query += " LEFT JOIN service_ratings ON tickets.guild_id = service_ratings.guild_id AND tickets.id = service_ratings.ticket_id " } if o.ClosedById != 0 || o.CloseReasonSearch != "" { - query += " INNER JOIN close_reason ON tickets.guild_id = close_reason.guild_id AND tickets.id = close_reason.ticket_id " + query += " LEFT JOIN close_reason ON tickets.guild_id = close_reason.guild_id AND tickets.id = close_reason.ticket_id " } if o.ClaimedById != 0 { From 1492d384c2e219d17f50110a54600d03bff27ba1 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:32:30 +0200 Subject: [PATCH 49/49] Exclude ticket creators from participant counts Update participant count queries to join `tickets` and ignore rows where `participant.user_id` matches `tickets.user_id`. This prevents ticket owners from being counted as participants in both total and duration-based participation metrics. --- participants.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/participants.go b/participants.go index 09329bf..ba953e6 100644 --- a/participants.go +++ b/participants.go @@ -145,7 +145,9 @@ func (p *ParticipantTable) GetParticipatedCount(ctx context.Context, guildId, us query := ` SELECT COUNT(*) FROM participant -WHERE "guild_id" = $1 AND "user_id" = $2; +INNER JOIN tickets +ON tickets.guild_id = participant.guild_id AND tickets.id = participant.ticket_id +WHERE participant.guild_id = $1 AND participant.user_id = $2 AND participant.user_id != tickets.user_id; ` err = p.QueryRow(ctx, query, guildId, userId).Scan(&count) @@ -163,7 +165,10 @@ SELECT COUNT(*) FROM participant INNER JOIN tickets ON tickets.guild_id = participant.guild_id AND tickets.id = participant.ticket_id -WHERE participant.guild_id = $1 AND participant.user_id = $2 AND tickets.open_time > NOW() - $3::interval; +WHERE participant.guild_id = $1 + AND participant.user_id = $2 + AND participant.user_id != tickets.user_id + AND tickets.open_time > NOW() - $3::interval; ` err = p.QueryRow(ctx, query, guildId, userId, parsed).Scan(&count)