Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
module github.com/TicketsBot-cloud/database

go 1.22
go 1.22.0

toolchain go1.22.4

//replace github.com/TicketsBot-cloud/common => ../common

require (
github.com/TicketsBot-cloud/common v0.0.0-20250208132851-d5083bb04d98
github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4
github.com/google/uuid v1.6.0
github.com/jackc/pgtype v1.14.0
github.com/jackc/pgx v3.6.2+incompatible
github.com/jackc/pgx/v4 v4.18.3
github.com/json-iterator/go v1.1.12
github.com/sirupsen/logrus v1.9.0
go.uber.org/zap v1.27.1
)

Expand All @@ -28,6 +29,5 @@ require (
github.com/pkg/errors v0.9.1 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
)
15 changes: 6 additions & 9 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/TicketsBot-cloud/common v0.0.0-20250208132851-d5083bb04d98 h1:HwtXrqSv6y5E8mTnkOtISTaQr6dsK/lfUC8tUA6SzSU=
github.com/TicketsBot-cloud/common v0.0.0-20250208132851-d5083bb04d98/go.mod h1:iiZhl7w5DeTEGzAeq/hzani8+ILQz60g4JFkOy2vkuM=
github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e h1:66mDH3lvotbWSgQXNVbssCvUgy6Yg1mqKl7RmEdr6y4=
github.com/TicketsBot-cloud/common v0.0.0-20260827064609-69131fc7bd3e/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g=
github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4 h1:zcPcJ+03xW9qalqzqllM6j0ZPdhuL+mrtGw8woLrAMo=
github.com/TicketsBot-cloud/common v0.0.0-20260905165836-38e4090764a4/go.mod h1:yL+VPSYNVK5gxUkA+fbb0WdTjGfWXnr7ScfL0rwk29g=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
Expand Down Expand Up @@ -109,8 +111,6 @@ github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXY
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
Expand All @@ -119,8 +119,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
Expand Down Expand Up @@ -171,9 +171,6 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand Down
235 changes: 151 additions & 84 deletions guildpurge.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,116 +4,183 @@ import (
"context"
"fmt"

"github.com/jackc/pgx/v4"
"go.uber.org/zap"
)

// Runs before guildPurgeTables so the panel children go while panels still exists.
var guildPurgeIndirect = []struct {
table string
query string
}{
// No foreign key to panels, so deleting panels would orphan these.
{"panel_ticket_permissions", `DELETE FROM panel_ticket_permissions WHERE panel_id IN (SELECT panel_id FROM panels WHERE guild_id = $1)`},
{"panel_kb_categories", `DELETE FROM panel_kb_categories WHERE panel_id IN (SELECT panel_id FROM panels WHERE guild_id = $1)`},

{"gallery_listings", `DELETE FROM gallery_listings WHERE source_guild_id = $1`},
{"experiment_exposures", `DELETE FROM experiment_exposures WHERE identifier_type = 'guild_id' AND identifier = ($1::int8)::text`},
}

// Order is load-bearing: most children of tickets have no ON DELETE action, so
// listing one after its parent raises SQLSTATE 23503.
var guildPurgeTables = []string{
"archive_dm_messages",
"archive_messages",
"auto_close_exclude",
"category_update_queue",
"close_reason",
"close_request",
"exit_survey_responses",
"first_response_time",
"participant",
"service_ratings",
"ticket_claims",
"ticket_label_assignments",
"ticket_last_message",
"ticket_members",
"ticket_message_counts",
"webhooks",

"tickets",
"guild_ticket_counters",
"ticket_labels",

// Panels reference embeds and forms.
"panels",
"multi_panels",

"support_team",
"forms",
"embeds",

"custom_integration_secret_values",
"custom_integration_guilds",

"kb_article_feedback",
"kb_articles",
"kb_categories",
"kb_settings",

"active_language",
"audit_logs",
"blacklist",
"channel_category",
"claim_settings",
"custom_colours",
"dashboard_onboarding",
"guild_metadata",
"legacy_premium_entitlement_guilds",
"on_call",
"permissions",
"premium_guilds",
"role_blacklist",
"role_permissions",
"settings",
"staff_override",
"tags",
"used_keys",
"user_guilds",
"whitelabel_allowed_guilds",
"whitelabel_guilds",
}

// Dropped from the schema, but dropping a Go wrapper does not drop the table, so
// deployments may still hold rows. Remove an entry once production is confirmed clean.
var guildPurgeLegacy = []string{
"archive_channel",
"auto_close",
"close_confirmation",
"feedback_enabled",
"import_logs",
"import_mapping",
"naming_scheme",
"ticket_limit",
"ticket_permissions",
"users_can_close",
"welcome_messages",
}

// Guild-scoped but deliberately retained; keeps the drift test honest.
var guildPurgeExempt = map[string]string{
"guild_leave_time": "drives the purge itself; the caller deletes the row once the purge succeeds",
"entitlements": "billing record, retained beyond the guild",
"server_blacklist": "global ban list; purging it would silently un-ban the guild",
}

// PurgeGuildData deletes all data associated with a guild from all tables.
func (d *Database) PurgeGuildData(ctx context.Context, guildId uint64, logger *zap.Logger) error {
logger.Info("Starting guild data purge", zap.Uint64("guild_id", guildId))

// Start a transaction for atomicity
tx, err := d.BeginTx(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}

defer tx.Rollback(ctx)

// Tables with direct guild_id column
// will be automatically deleted via ON DELETE CASCADE foreign key constraints
directGuildIdTables := []string{
// Ticket-related child tables (must be deleted before tickets)
"archive_messages",
"auto_close_exclude",
"category_update_queue",
"close_reason",
"close_request",
"exit_survey_responses",
"first_response_time",
"participant",
"service_ratings",
"ticket_claims",
"ticket_last_message",
"ticket_members",

// Tickets table and its counter
"tickets",
"guild_ticket_counters",

// Panels table
"panels",
"multi_panels",

// Support team related
"support_team",

// Form-related
"forms",

// Embed-related
"embeds",

// Custom integration related
"custom_integration_secret_values",
"custom_integration_guilds",

// Other guild-specific tables
"active_language",
"archive_channel",
"blacklist",
"channel_category",
"claim_settings",
"custom_colours",
"guild_metadata",
"legacy_premium_entitlement_guilds",
"naming_scheme",
"on_call",
"permissions",
"premium_guilds",
"role_blacklist",
"role_permissions",
"settings",
"staff_override",
"tags",
"ticket_limit",
"ticket_permissions",
"user_guilds",
"webhooks",
"welcome_messages",
"whitelabel_guilds",
for _, t := range guildPurgeIndirect {
if err := purgeTable(ctx, tx, logger, guildId, t.table, t.query); err != nil {
return err
}
}

// Delete from tables with direct guild_id column
// Child tables are automatically deleted via CASCADE
for _, table := range directGuildIdTables {
for _, table := range guildPurgeTables {
query := fmt.Sprintf(`DELETE FROM %s WHERE guild_id = $1`, table)
result, err := tx.Exec(ctx, query, guildId)
if err != nil {
logger.Error(
"Failed to delete from table",
zap.String("table", table),
zap.Uint64("guild_id", guildId),
zap.Error(err),
)
return fmt.Errorf("failed to delete from %s: %w", table, err)
if err := purgeTable(ctx, tx, logger, guildId, table, query); err != nil {
return err
}
}

rowsAffected := result.RowsAffected()
if rowsAffected > 0 {
logger.Info(
"Deleted rows from table",
zap.String("table", table),
zap.Uint64("guild_id", guildId),
zap.Int64("rows_deleted", rowsAffected),
)
for _, table := range guildPurgeLegacy {
query := fmt.Sprintf(`DELETE FROM %s WHERE guild_id = $1`, table)
if err := purgeTable(ctx, tx, logger, guildId, table, query); err != nil {
return err
}
}

// Commit the transaction
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}

logger.Info("Successfully completed guild data purge", zap.Uint64("guild_id", guildId))
return nil
}

func purgeTable(ctx context.Context, tx pgx.Tx, logger *zap.Logger, guildId uint64, table, query string) error {
// A missing relation aborts the whole transaction, blocking every purge.
var exists bool
if err := tx.QueryRow(ctx, `SELECT to_regclass($1) IS NOT NULL`, table).Scan(&exists); err != nil {
return fmt.Errorf("failed to check existence of %s: %w", table, err)
}

if !exists {
logger.Warn(
"Skipping table that no longer exists",
zap.String("table", table),
zap.Uint64("guild_id", guildId),
)
return nil
}

result, err := tx.Exec(ctx, query, guildId)
if err != nil {
logger.Error(
"Failed to delete from table",
zap.String("table", table),
zap.Uint64("guild_id", guildId),
zap.Error(err),
)
return fmt.Errorf("failed to delete from %s: %w", table, err)
}

if rowsAffected := result.RowsAffected(); rowsAffected > 0 {
logger.Info(
"Deleted rows from table",
zap.String("table", table),
zap.Uint64("guild_id", guildId),
zap.Int64("rows_deleted", rowsAffected),
)
}

return nil
}
Loading