From e95bfc83e5ec54d7441047468d31386c408ab5ee Mon Sep 17 00:00:00 2001 From: caydyan Date: Sun, 14 Jun 2026 19:07:32 +0800 Subject: [PATCH] Archive inactive tribes --- db/db_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++++ db/tribe_archive.go | 20 ++++++++++++ handlers/tribes.go | 28 ++++++++++++++++ main.go | 1 + mocks/Database.go | 58 +++++++++++++++++++++++++++++++- 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 db/tribe_archive.go diff --git a/db/db_test.go b/db/db_test.go index 27aac0a71..c1b2c36d8 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -144,6 +144,86 @@ func TestGetFilterStatusCount(t *testing.T) { } } +func TestArchiveInactiveTribes(t *testing.T) { + InitTestDB() + defer CloseTestDB() + + now := time.Now() + cutoff := now.AddDate(0, 0, -30).Unix() + oldActive := now.AddDate(0, 0, -45).Unix() + recentActive := now.AddDate(0, 0, -5).Unix() + + tribes := []Tribe{ + { + UUID: "stale-listed-tribe", + OwnerPubKey: "owner-1", + Name: "stale listed tribe", + LastActive: oldActive, + Unlisted: false, + Deleted: false, + }, + { + UUID: "recent-listed-tribe", + OwnerPubKey: "owner-2", + Name: "recent listed tribe", + LastActive: recentActive, + Unlisted: false, + Deleted: false, + }, + { + UUID: "legacy-listed-tribe", + OwnerPubKey: "owner-3", + Name: "legacy listed tribe", + LastActive: 0, + Unlisted: false, + Deleted: false, + }, + { + UUID: "stale-unlisted-tribe", + OwnerPubKey: "owner-4", + Name: "stale unlisted tribe", + LastActive: oldActive, + Unlisted: true, + Deleted: false, + }, + { + UUID: "stale-deleted-tribe", + OwnerPubKey: "owner-5", + Name: "stale deleted tribe", + LastActive: oldActive, + Unlisted: false, + Deleted: true, + }, + } + for _, tribe := range tribes { + assert.NoError(t, TestDB.db.Create(&tribe).Error) + } + + archived, err := TestDB.ArchiveInactiveTribes(cutoff) + assert.NoError(t, err) + assert.Equal(t, int64(1), archived) + + var got []Tribe + assert.NoError(t, TestDB.db.Where("uuid IN ?", []string{ + "stale-listed-tribe", + "recent-listed-tribe", + "legacy-listed-tribe", + "stale-unlisted-tribe", + "stale-deleted-tribe", + }).Find(&got).Error) + + byUUID := make(map[string]Tribe, len(got)) + for _, tribe := range got { + byUUID[tribe.UUID] = tribe + } + + assert.True(t, byUUID["stale-listed-tribe"].Unlisted) + assert.False(t, byUUID["recent-listed-tribe"].Unlisted) + assert.False(t, byUUID["legacy-listed-tribe"].Unlisted) + assert.True(t, byUUID["stale-unlisted-tribe"].Unlisted) + assert.False(t, byUUID["stale-deleted-tribe"].Unlisted) +} + func TestCreateConnectionCode(t *testing.T) { InitTestDB() diff --git a/db/tribe_archive.go b/db/tribe_archive.go new file mode 100644 index 000000000..5a7851303 --- /dev/null +++ b/db/tribe_archive.go @@ -0,0 +1,20 @@ +package db + +import "time" + +func (db database) ArchiveInactiveTribes(cutoffUnix int64) (int64, error) { + if cutoffUnix <= 0 { + return 0, nil + } + + now := time.Now() + result := db.db.Model(&Tribe{}). + Where("(unlisted = ? OR unlisted IS NULL) AND (deleted = ? OR deleted IS NULL)", false, false). + Where("last_active > 0 AND last_active < ?", cutoffUnix). + Updates(map[string]interface{}{ + "unlisted": true, + "updated": &now, + }) + + return result.RowsAffected, result.Error +} diff --git a/handlers/tribes.go b/handlers/tribes.go index d118b7fd7..1fc893888 100644 --- a/handlers/tribes.go +++ b/handlers/tribes.go @@ -7,6 +7,7 @@ import ( "io" "log" "net/http" + "os" "strconv" "strings" "time" @@ -25,6 +26,8 @@ type tribeHandler struct { tribeUniqueNameFromName func(name string) (string, error) } +const defaultTribeArchiveAfterDays = 30 + func NewTribeHandler(db db.Database) *tribeHandler { return &tribeHandler{ db: db, @@ -377,6 +380,31 @@ func (th *tribeHandler) CreateOrEditTribe(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(tribe) } +func ArchiveInactiveTribes() { + days := defaultTribeArchiveAfterDays + if envDays := os.Getenv("TRIBE_ARCHIVE_AFTER_DAYS"); envDays != "" { + parsedDays, err := strconv.Atoi(envDays) + if err != nil { + logger.Log.Error("invalid TRIBE_ARCHIVE_AFTER_DAYS: %v", err) + return + } + if parsedDays <= 0 { + return + } + days = parsedDays + } + + cutoff := time.Now().AddDate(0, 0, -days).Unix() + count, err := db.DB.ArchiveInactiveTribes(cutoff) + if err != nil { + logger.Log.Error("failed to archive inactive tribes: %v", err) + return + } + if count > 0 { + logger.Log.Info("archived %d inactive tribes", count) + } +} + // PutTribeActivity godoc // // @Summary Update tribe activity diff --git a/main.go b/main.go index ef8c56b32..322871172 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,7 @@ func runCron() { c := cron.New() c.AddFunc("@every 0h30m0s", handlers.InitV2PaymentsCron) c.AddFunc("@every 0h0m30s", handlers.ProcessWaitingNotifications) + c.AddFunc("@every 24h0m0s", handlers.ArchiveInactiveTribes) c.Start() } diff --git a/mocks/Database.go b/mocks/Database.go index 5c98b7f66..15f0a18b8 100644 --- a/mocks/Database.go +++ b/mocks/Database.go @@ -5696,6 +5696,62 @@ func (_c *Database_GetBountyByCreated_Call) RunAndReturn(run func(uint) (db.NewB return _c } +// GetBountyByUnlockCode provides a mock function with given fields: code +func (_m *Database) GetBountyByUnlockCode(code string) (db.NewBounty, error) { + ret := _m.Called(code) + + if len(ret) == 0 { + panic("no return value specified for GetBountyByUnlockCode") + } + + var r0 db.NewBounty + var r1 error + if rf, ok := ret.Get(0).(func(string) (db.NewBounty, error)); ok { + return rf(code) + } + if rf, ok := ret.Get(0).(func(string) db.NewBounty); ok { + r0 = rf(code) + } else { + r0 = ret.Get(0).(db.NewBounty) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(code) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Database_GetBountyByUnlockCode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBountyByUnlockCode' +type Database_GetBountyByUnlockCode_Call struct { + *mock.Call +} + +// GetBountyByUnlockCode is a helper method to define mock.On call +// - code string +func (_e *Database_Expecter) GetBountyByUnlockCode(code interface{}) *Database_GetBountyByUnlockCode_Call { + return &Database_GetBountyByUnlockCode_Call{Call: _e.mock.On("GetBountyByUnlockCode", code)} +} + +func (_c *Database_GetBountyByUnlockCode_Call) Run(run func(code string)) *Database_GetBountyByUnlockCode_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetBountyByUnlockCode_Call) Return(_a0 db.NewBounty, _a1 error) *Database_GetBountyByUnlockCode_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_GetBountyByUnlockCode_Call) RunAndReturn(run func(string) (db.NewBounty, error)) *Database_GetBountyByUnlockCode_Call { + _c.Call.Return(run) + return _c +} + // GetBountyById provides a mock function with given fields: id func (_m *Database) GetBountyById(id string) ([]db.NewBounty, error) { ret := _m.Called(id) @@ -18423,4 +18479,4 @@ func (_c *Database_DeleteBountyStakeProcess_Call) Return(_a0 error) *Database_De func (_c *Database_DeleteBountyStakeProcess_Call) RunAndReturn(run func(uuid.UUID) error) *Database_DeleteBountyStakeProcess_Call { _c.Call.Return(run) return _c -} \ No newline at end of file +}