diff options
author | 2023-03-20 19:10:08 +0100 | |
---|---|---|
committer | 2023-03-20 18:10:08 +0000 | |
commit | e8595f0c64f527af0913d1a426b697e67ff74ac9 (patch) | |
tree | a5d45b1ad8b96318944408a23fda91f008643900 /internal/db | |
parent | [chore]: Bump github.com/miekg/dns from 1.1.51 to 1.1.52 (#1636) (diff) | |
download | gotosocial-e8595f0c64f527af0913d1a426b697e67ff74ac9.tar.xz |
[chore] Refactor account deleting/block logic, tidy up some other processing things (#1599)
* start refactoring account deletion
* update to use state.DB
* further messing about
* some more tidying up
* more tidying, cleaning, nice-making
* further adventures in refactoring and the woes of technical debt
* update fr accept/reject
* poking + prodding
* fix up deleting
* create fave uri
* don't log using requestingAccount.ID because it might be nil
* move getBookmarks function
* use exists query to check for status bookmark
* use deletenotifications func
* fiddle
* delete follow request notif
* split up some db functions
* Fix possible nil pointer panic
* fix more possible nil pointers
* fix license headers
* warn when follow missing (target) account
* return wrapped err when bookmark/fave models can't be retrieved
* simplify self account delete
* warn log likely race condition
* de-sillify status delete loop
* move error check due north
* warn when unfollowSideEffects has no target account
* warn when no boost account is found
* warn + dump follow when no account
* more warnings
* warn on fave account not set
* move for loop inside anonymous function
* fix funky logic
* don't remove mutual account items on block;
do make sure unfollow occurs in both directions!
Diffstat (limited to 'internal/db')
-rw-r--r-- | internal/db/account.go | 4 | ||||
-rw-r--r-- | internal/db/bundb/account.go | 40 | ||||
-rw-r--r-- | internal/db/bundb/account_test.go | 6 | ||||
-rw-r--r-- | internal/db/bundb/bundb.go | 10 | ||||
-rw-r--r-- | internal/db/bundb/bundb_test.go | 4 | ||||
-rw-r--r-- | internal/db/bundb/notification.go | 62 | ||||
-rw-r--r-- | internal/db/bundb/notification_test.go | 73 | ||||
-rw-r--r-- | internal/db/bundb/relationship.go | 389 | ||||
-rw-r--r-- | internal/db/bundb/relationship_test.go | 129 | ||||
-rw-r--r-- | internal/db/bundb/status.go | 23 | ||||
-rw-r--r-- | internal/db/bundb/statusbookmark.go | 198 | ||||
-rw-r--r-- | internal/db/bundb/statusbookmark_test.go | 129 | ||||
-rw-r--r-- | internal/db/bundb/statusfave.go | 172 | ||||
-rw-r--r-- | internal/db/bundb/statusfave_test.go | 148 | ||||
-rw-r--r-- | internal/db/db.go | 2 | ||||
-rw-r--r-- | internal/db/notification.go | 27 | ||||
-rw-r--r-- | internal/db/relationship.go | 63 | ||||
-rw-r--r-- | internal/db/status.go | 4 | ||||
-rw-r--r-- | internal/db/statusbookmark.go | 66 | ||||
-rw-r--r-- | internal/db/statusfave.go | 63 |
20 files changed, 1389 insertions, 223 deletions
diff --git a/internal/db/account.go b/internal/db/account.go index 419337d6e..6ecfea018 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -45,7 +45,7 @@ type Account interface { PutAccount(ctx context.Context, account *gtsmodel.Account) Error // UpdateAccount updates one account by ID. - UpdateAccount(ctx context.Context, account *gtsmodel.Account) Error + UpdateAccount(ctx context.Context, account *gtsmodel.Account, columns ...string) Error // DeleteAccount deletes one account from the database by its ID. // DO NOT USE THIS WHEN SUSPENDING ACCOUNTS! In that case you should mark the @@ -86,8 +86,6 @@ type Account interface { // In the case of no statuses, this function will return db.ErrNoEntries. GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, Error) - GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, Error) - GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error) // GetAccountLastPosted simply gets the timestamp of the most recent post by the account. diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 17f2cae65..df73168e2 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -191,9 +191,12 @@ func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) d }) } -func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account) db.Error { - // Update the account's last-updated +func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account, columns ...string) db.Error { account.UpdatedAt = time.Now() + if len(columns) > 0 { + // If we're updating by column, ensure "updated_at" is included. + columns = append(columns, "updated_at") + } return a.state.Caches.GTS.Account().Store(account, func() error { // It is safe to run this database transaction within cache.Store @@ -226,6 +229,7 @@ func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account _, err := tx.NewUpdate(). Model(account). Where("? = ?", bun.Ident("account.id"), account.ID). + Column(columns...). Exec(ctx) return err }) @@ -477,38 +481,6 @@ func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string, return a.statusesFromIDs(ctx, statusIDs) } -func (a *accountDB) GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, db.Error) { - bookmarks := []*gtsmodel.StatusBookmark{} - - q := a.conn. - NewSelect(). - TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). - Order("status_bookmark.id DESC"). - Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID) - - if accountID == "" { - return nil, errors.New("must provide an account") - } - - if limit != 0 { - q = q.Limit(limit) - } - - if maxID != "" { - q = q.Where("? < ?", bun.Ident("status_bookmark.id"), maxID) - } - - if minID != "" { - q = q.Where("? > ?", bun.Ident("status_bookmark.id"), minID) - } - - if err := q.Scan(ctx, &bookmarks); err != nil { - return nil, a.conn.ProcessError(err) - } - - return bookmarks, nil -} - func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) { blocks := []*gtsmodel.Block{} diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go index 1bf7c654d..b7e8aaadc 100644 --- a/internal/db/bundb/account_test.go +++ b/internal/db/bundb/account_test.go @@ -208,12 +208,6 @@ func (suite *AccountTestSuite) TestInsertAccountWithDefaults() { suite.False(*newAccount.HideCollections) } -func (suite *AccountTestSuite) TestGettingBookmarksWithNoAccount() { - statuses, err := suite.db.GetBookmarks(context.Background(), "", 10, "", "") - suite.Error(err) - suite.Nil(statuses) -} - func (suite *AccountTestSuite) TestGetAccountPinnedStatusesSomeResults() { testAccount := suite.testAccounts["admin_account"] diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go index d20c81ad0..71433dbc2 100644 --- a/internal/db/bundb/bundb.go +++ b/internal/db/bundb/bundb.go @@ -71,6 +71,8 @@ type DBService struct { db.Report db.Session db.Status + db.StatusBookmark + db.StatusFave db.Timeline db.User db.Tombstone @@ -203,6 +205,14 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) { conn: conn, state: state, }, + StatusBookmark: &statusBookmarkDB{ + conn: conn, + state: state, + }, + StatusFave: &statusFaveDB{ + conn: conn, + state: state, + }, Timeline: &timelineDB{ conn: conn, state: state, diff --git a/internal/db/bundb/bundb_test.go b/internal/db/bundb/bundb_test.go index 843978f24..e6d482ac1 100644 --- a/internal/db/bundb/bundb_test.go +++ b/internal/db/bundb/bundb_test.go @@ -44,6 +44,8 @@ type BunDBStandardTestSuite struct { testFollows map[string]*gtsmodel.Follow testEmojis map[string]*gtsmodel.Emoji testReports map[string]*gtsmodel.Report + testBookmarks map[string]*gtsmodel.StatusBookmark + testFaves map[string]*gtsmodel.StatusFave } func (suite *BunDBStandardTestSuite) SetupSuite() { @@ -59,6 +61,8 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { suite.testFollows = testrig.NewTestFollows() suite.testEmojis = testrig.NewTestEmojis() suite.testReports = testrig.NewTestReports() + suite.testBookmarks = testrig.NewTestBookmarks() + suite.testFaves = testrig.NewTestFaves() } func (suite *BunDBStandardTestSuite) SetupTest() { diff --git a/internal/db/bundb/notification.go b/internal/db/bundb/notification.go index 627dc1783..b1e7f45ff 100644 --- a/internal/db/bundb/notification.go +++ b/internal/db/bundb/notification.go @@ -19,6 +19,7 @@ package bundb import ( "context" + "errors" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" @@ -104,15 +105,70 @@ func (n *notificationDB) GetNotifications(ctx context.Context, accountID string, return notifs, nil } -func (n *notificationDB) ClearNotifications(ctx context.Context, accountID string) db.Error { +func (n *notificationDB) DeleteNotification(ctx context.Context, id string) db.Error { if _, err := n.conn. NewDelete(). TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")). - Where("? = ?", bun.Ident("notification.target_account_id"), accountID). + Where("? = ?", bun.Ident("notification.id"), id). Exec(ctx); err != nil { return n.conn.ProcessError(err) } - n.state.Caches.GTS.Notification().Clear() + n.state.Caches.GTS.Notification().Invalidate("ID", id) + return nil +} + +func (n *notificationDB) DeleteNotifications(ctx context.Context, targetAccountID string, originAccountID string) db.Error { + if targetAccountID == "" && originAccountID == "" { + return errors.New("DeleteNotifications: one of targetAccountID or originAccountID must be set") + } + + // Capture notification IDs in a RETURNING statement. + ids := []string{} + + q := n.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")). + Returning("?", bun.Ident("id")) + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("notification.target_account_id"), targetAccountID) + } + + if originAccountID != "" { + q = q.Where("? = ?", bun.Ident("notification.origin_account_id"), originAccountID) + } + + if _, err := q.Exec(ctx, &ids); err != nil { + return n.conn.ProcessError(err) + } + + // Invalidate each returned ID. + for _, id := range ids { + n.state.Caches.GTS.Notification().Invalidate("ID", id) + } + + return nil +} + +func (n *notificationDB) DeleteNotificationsForStatus(ctx context.Context, statusID string) db.Error { + // Capture notification IDs in a RETURNING statement. + ids := []string{} + + q := n.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")). + Where("? = ?", bun.Ident("notification.status_id"), statusID). + Returning("?", bun.Ident("id")) + + if _, err := q.Exec(ctx, &ids); err != nil { + return n.conn.ProcessError(err) + } + + // Invalidate each returned ID. + for _, id := range ids { + n.state.Caches.GTS.Notification().Invalidate("ID", id) + } + return nil } diff --git a/internal/db/bundb/notification_test.go b/internal/db/bundb/notification_test.go index 450ecc332..117fc329c 100644 --- a/internal/db/bundb/notification_test.go +++ b/internal/db/bundb/notification_test.go @@ -19,11 +19,13 @@ package bundb_test import ( "context" + "errors" "fmt" "testing" "time" "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/testrig" @@ -112,10 +114,10 @@ func (suite *NotificationTestSuite) TestGetNotificationsWithoutSpam() { } } -func (suite *NotificationTestSuite) TestClearNotificationsWithSpam() { +func (suite *NotificationTestSuite) TestDeleteNotificationsWithSpam() { suite.spamNotifs() testAccount := suite.testAccounts["local_account_1"] - err := suite.db.ClearNotifications(context.Background(), testAccount.ID) + err := suite.db.DeleteNotifications(context.Background(), testAccount.ID, "") suite.NoError(err) notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, id.Highest, id.Lowest) @@ -124,10 +126,10 @@ func (suite *NotificationTestSuite) TestClearNotificationsWithSpam() { suite.Empty(notifications) } -func (suite *NotificationTestSuite) TestClearNotificationsWithTwoAccounts() { +func (suite *NotificationTestSuite) TestDeleteNotificationsWithTwoAccounts() { suite.spamNotifs() testAccount := suite.testAccounts["local_account_1"] - err := suite.db.ClearNotifications(context.Background(), testAccount.ID) + err := suite.db.DeleteNotifications(context.Background(), testAccount.ID, "") suite.NoError(err) notifications, err := suite.db.GetNotifications(context.Background(), testAccount.ID, []string{}, 20, id.Highest, id.Lowest) @@ -141,6 +143,69 @@ func (suite *NotificationTestSuite) TestClearNotificationsWithTwoAccounts() { suite.NotEmpty(notif) } +func (suite *NotificationTestSuite) TestDeleteNotificationsOriginatingFromAccount() { + testAccount := suite.testAccounts["local_account_2"] + + if err := suite.db.DeleteNotifications(context.Background(), "", testAccount.ID); err != nil { + suite.FailNow(err.Error()) + } + + notif := []*gtsmodel.Notification{} + if err := suite.db.GetAll(context.Background(), ¬if); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, n := range notif { + if n.OriginAccountID == testAccount.ID { + suite.FailNowf("", "no notifications with origin account id %s should remain", testAccount.ID) + } + } +} + +func (suite *NotificationTestSuite) TestDeleteNotificationsOriginatingFromAndTargetingAccount() { + originAccount := suite.testAccounts["local_account_2"] + targetAccount := suite.testAccounts["admin_account"] + + if err := suite.db.DeleteNotifications(context.Background(), targetAccount.ID, originAccount.ID); err != nil { + suite.FailNow(err.Error()) + } + + notif := []*gtsmodel.Notification{} + if err := suite.db.GetAll(context.Background(), ¬if); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, n := range notif { + if n.OriginAccountID == originAccount.ID || n.TargetAccountID == targetAccount.ID { + suite.FailNowf( + "", + "no notifications with origin account id %s and target account %s should remain", + originAccount.ID, + targetAccount.ID, + ) + } + } +} + +func (suite *NotificationTestSuite) TestDeleteNotificationsPertainingToStatusID() { + testStatus := suite.testStatuses["local_account_1_status_1"] + + if err := suite.db.DeleteNotificationsForStatus(context.Background(), testStatus.ID); err != nil { + suite.FailNow(err.Error()) + } + + notif := []*gtsmodel.Notification{} + if err := suite.db.GetAll(context.Background(), ¬if); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, n := range notif { + if n.StatusID == testStatus.ID { + suite.FailNowf("", "no notifications with status id %s should remain", testStatus.ID) + } + } +} + func TestNotificationTestSuite(t *testing.T) { suite.Run(t, new(NotificationTestSuite)) } diff --git a/internal/db/bundb/relationship.go b/internal/db/bundb/relationship.go index 90f9cebe7..21a29b5dc 100644 --- a/internal/db/bundb/relationship.go +++ b/internal/db/bundb/relationship.go @@ -19,12 +19,12 @@ package bundb import ( "context" - "database/sql" "errors" "fmt" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/uptrace/bun" ) @@ -34,14 +34,6 @@ type relationshipDB struct { state *state.State } -func (r *relationshipDB) newFollowQ(follow interface{}) *bun.SelectQuery { - return r.conn. - NewSelect(). - Model(follow). - Relation("Account"). - Relation("TargetAccount") -} - func (r *relationshipDB) IsBlocked(ctx context.Context, account1 string, account2 string, eitherDirection bool) (bool, db.Error) { // Look for a block in direction of account1->account2 block1, err := r.getBlock(ctx, account1, account2) @@ -305,169 +297,340 @@ func (r *relationshipDB) IsMutualFollowing(ctx context.Context, account1 *gtsmod } func (r *relationshipDB) AcceptFollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (*gtsmodel.Follow, db.Error) { - var follow *gtsmodel.Follow - - if err := r.conn.RunInTx(ctx, func(tx bun.Tx) error { - // get original follow request - followRequest := >smodel.FollowRequest{} - if err := tx. - NewSelect(). - Model(followRequest). - Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). - Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). - Scan(ctx); err != nil { - return err - } + // Get original follow request. + var followRequestID string + if err := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Column("follow_request.id"). + Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). + Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). + Scan(ctx, &followRequestID); err != nil { + return nil, r.conn.ProcessError(err) + } - // create a new follow to 'replace' the request with - follow = >smodel.Follow{ - ID: followRequest.ID, - AccountID: originAccountID, - TargetAccountID: targetAccountID, - URI: followRequest.URI, - } + followRequest, err := r.getFollowRequest(ctx, followRequestID) + if err != nil { + return nil, r.conn.ProcessError(err) + } - // if the follow already exists, just update the URI -- we don't need to do anything else - if _, err := tx. - NewInsert(). - Model(follow). - On("CONFLICT (?,?) DO UPDATE set ? = ?", bun.Ident("account_id"), bun.Ident("target_account_id"), bun.Ident("uri"), follow.URI). - Exec(ctx); err != nil { - return err - } + // Create a new follow to 'replace' + // the original follow request with. + follow := >smodel.Follow{ + ID: followRequest.ID, + AccountID: originAccountID, + Account: followRequest.Account, + TargetAccountID: targetAccountID, + TargetAccount: followRequest.TargetAccount, + URI: followRequest.URI, + } - // now remove the follow request - if _, err := tx. - NewDelete(). - TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). - Where("? = ?", bun.Ident("follow_request.id"), followRequest.ID). - Exec(ctx); err != nil { - return err - } + // If the follow already exists, just + // replace the URI with the new one. + if _, err := r.conn. + NewInsert(). + Model(follow). + On("CONFLICT (?,?) DO UPDATE set ? = ?", bun.Ident("account_id"), bun.Ident("target_account_id"), bun.Ident("uri"), follow.URI). + Exec(ctx); err != nil { + return nil, r.conn.ProcessError(err) + } - return nil - }); err != nil { + // Delete original follow request. + if _, err := r.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Where("? = ?", bun.Ident("follow_request.id"), followRequest.ID). + Exec(ctx); err != nil { return nil, r.conn.ProcessError(err) } + // Delete original follow request notification. + if err := r.deleteFollowRequestNotif(ctx, originAccountID, targetAccountID); err != nil { + return nil, err + } + // return the new follow return follow, nil } func (r *relationshipDB) RejectFollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (*gtsmodel.FollowRequest, db.Error) { - followRequest := >smodel.FollowRequest{} - - if err := r.conn.RunInTx(ctx, func(tx bun.Tx) error { - // get original follow request - if err := tx. - NewSelect(). - Model(followRequest). - Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). - Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). - Scan(ctx); err != nil { - return err - } + // Get original follow request. + var followRequestID string + if err := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Column("follow_request.id"). + Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). + Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). + Scan(ctx, &followRequestID); err != nil { + return nil, r.conn.ProcessError(err) + } - // now delete it from the database by ID - if _, err := tx. - NewDelete(). - TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). - Where("? = ?", bun.Ident("follow_request.id"), followRequest.ID). - Exec(ctx); err != nil { - return err - } + followRequest, err := r.getFollowRequest(ctx, followRequestID) + if err != nil { + return nil, r.conn.ProcessError(err) + } - return nil - }); err != nil { + // Delete original follow request. + if _, err := r.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Where("? = ?", bun.Ident("follow_request.id"), followRequest.ID). + Exec(ctx); err != nil { return nil, r.conn.ProcessError(err) } - // return the deleted follow request + // Delete original follow request notification. + if err := r.deleteFollowRequestNotif(ctx, originAccountID, targetAccountID); err != nil { + return nil, err + } + + // Return the now deleted follow request. return followRequest, nil } -func (r *relationshipDB) GetAccountFollowRequests(ctx context.Context, accountID string) ([]*gtsmodel.FollowRequest, db.Error) { - followRequests := []*gtsmodel.FollowRequest{} +func (r *relationshipDB) deleteFollowRequestNotif(ctx context.Context, originAccountID string, targetAccountID string) db.Error { + var id string + if err := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("notifications"), bun.Ident("notification")). + Column("notification.id"). + Where("? = ?", bun.Ident("notification.origin_account_id"), originAccountID). + Where("? = ?", bun.Ident("notification.target_account_id"), targetAccountID). + Where("? = ?", bun.Ident("notification.notification_type"), gtsmodel.NotificationFollowRequest). + Limit(1). // There should only be one! + Scan(ctx, &id); err != nil { + err = r.conn.ProcessError(err) + if errors.Is(err, db.ErrNoEntries) { + // If no entries, the notif didn't + // exist anyway so nothing to do here. + return nil + } + // Return on real error. + return err + } - q := r.newFollowQ(&followRequests). - Where("? = ?", bun.Ident("follow_request.target_account_id"), accountID). - Order("follow_request.updated_at DESC") + return r.state.DB.DeleteNotification(ctx, id) +} - if err := q.Scan(ctx); err != nil { +func (r *relationshipDB) getFollow(ctx context.Context, id string) (*gtsmodel.Follow, db.Error) { + follow := >smodel.Follow{} + + err := r.conn. + NewSelect(). + Model(follow). + Where("? = ?", bun.Ident("follow.id"), id). + Scan(ctx) + if err != nil { return nil, r.conn.ProcessError(err) } - return followRequests, nil + follow.Account, err = r.state.DB.GetAccountByID(ctx, follow.AccountID) + if err != nil { + log.Errorf(ctx, "error getting follow account %q: %v", follow.AccountID, err) + } + + follow.TargetAccount, err = r.state.DB.GetAccountByID(ctx, follow.TargetAccountID) + if err != nil { + log.Errorf(ctx, "error getting follow target account %q: %v", follow.TargetAccountID, err) + } + + return follow, nil +} + +func (r *relationshipDB) GetLocalFollowersIDs(ctx context.Context, targetAccountID string) ([]string, db.Error) { + accountIDs := []string{} + + // Select only the account ID of each follow. + q := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). + ColumnExpr("? AS ?", bun.Ident("follow.account_id"), bun.Ident("account_id")). + Where("? = ?", bun.Ident("follow.target_account_id"), targetAccountID) + + // Join on accounts table to select only + // those with NULL domain (local accounts). + q = q. + Join("JOIN ? AS ? ON ? = ?", + bun.Ident("accounts"), + bun.Ident("account"), + bun.Ident("follow.account_id"), + bun.Ident("account.id"), + ). + Where("? IS NULL", bun.Ident("account.domain")) + + // We don't *really* need to order these, + // but it makes it more consistent to do so. + q = q.Order("account_id DESC") + + if err := q.Scan(ctx, &accountIDs); err != nil { + return nil, r.conn.ProcessError(err) + } + + return accountIDs, nil } -func (r *relationshipDB) GetAccountFollows(ctx context.Context, accountID string) ([]*gtsmodel.Follow, db.Error) { - follows := []*gtsmodel.Follow{} +func (r *relationshipDB) GetFollows(ctx context.Context, accountID string, targetAccountID string) ([]*gtsmodel.Follow, db.Error) { + ids := []string{} - q := r.newFollowQ(&follows). - Where("? = ?", bun.Ident("follow.account_id"), accountID). + q := r.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). + Column("follow.id"). Order("follow.updated_at DESC") - if err := q.Scan(ctx); err != nil { + if accountID != "" { + q = q.Where("? = ?", bun.Ident("follow.account_id"), accountID) + } + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("follow.target_account_id"), targetAccountID) + } + + if err := q.Scan(ctx, &ids); err != nil { return nil, r.conn.ProcessError(err) } + follows := make([]*gtsmodel.Follow, 0, len(ids)) + for _, id := range ids { + follow, err := r.getFollow(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting follow %q: %v", id, err) + continue + } + + follows = append(follows, follow) + } + return follows, nil } -func (r *relationshipDB) CountAccountFollows(ctx context.Context, accountID string, localOnly bool) (int, db.Error) { +func (r *relationshipDB) CountFollows(ctx context.Context, accountID string, targetAccountID string) (int, db.Error) { q := r.conn. NewSelect(). - TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")) + TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). + Column("follow.id") - if localOnly { - q = q. - Join("JOIN ? AS ? ON ? = ?", bun.Ident("accounts"), bun.Ident("account"), bun.Ident("follow.target_account_id"), bun.Ident("account.id")). - Where("? = ?", bun.Ident("follow.account_id"), accountID). - Where("? IS NULL", bun.Ident("account.domain")) - } else { + if accountID != "" { q = q.Where("? = ?", bun.Ident("follow.account_id"), accountID) } + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("follow.target_account_id"), targetAccountID) + } + return q.Count(ctx) } -func (r *relationshipDB) GetAccountFollowedBy(ctx context.Context, accountID string, localOnly bool) ([]*gtsmodel.Follow, db.Error) { - follows := []*gtsmodel.Follow{} +func (r *relationshipDB) getFollowRequest(ctx context.Context, id string) (*gtsmodel.FollowRequest, db.Error) { + followRequest := >smodel.FollowRequest{} + + err := r.conn. + NewSelect(). + Model(followRequest). + Where("? = ?", bun.Ident("follow_request.id"), id). + Scan(ctx) + if err != nil { + return nil, r.conn.ProcessError(err) + } + + followRequest.Account, err = r.state.DB.GetAccountByID(ctx, followRequest.AccountID) + if err != nil { + log.Errorf(ctx, "error getting follow request account %q: %v", followRequest.AccountID, err) + } + + followRequest.TargetAccount, err = r.state.DB.GetAccountByID(ctx, followRequest.TargetAccountID) + if err != nil { + log.Errorf(ctx, "error getting follow request target account %q: %v", followRequest.TargetAccountID, err) + } + + return followRequest, nil +} + +func (r *relationshipDB) GetFollowRequests(ctx context.Context, accountID string, targetAccountID string) ([]*gtsmodel.FollowRequest, db.Error) { + ids := []string{} q := r.conn. NewSelect(). - Model(&follows). - Order("follow.updated_at DESC") + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Column("follow_request.id") - if localOnly { - q = q. - Join("JOIN ? AS ? ON ? = ?", bun.Ident("accounts"), bun.Ident("account"), bun.Ident("follow.account_id"), bun.Ident("account.id")). - Where("? = ?", bun.Ident("follow.target_account_id"), accountID). - Where("? IS NULL", bun.Ident("account.domain")) - } else { - q = q.Where("? = ?", bun.Ident("follow.target_account_id"), accountID) + if accountID != "" { + q = q.Where("? = ?", bun.Ident("follow_request.account_id"), accountID) + } + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID) } - err := q.Scan(ctx) - if err != nil && err != sql.ErrNoRows { + if err := q.Scan(ctx, &ids); err != nil { return nil, r.conn.ProcessError(err) } - return follows, nil + + followRequests := make([]*gtsmodel.FollowRequest, 0, len(ids)) + for _, id := range ids { + followRequest, err := r.getFollowRequest(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting follow request %q: %v", id, err) + continue + } + + followRequests = append(followRequests, followRequest) + } + + return followRequests, nil } -func (r *relationshipDB) CountAccountFollowedBy(ctx context.Context, accountID string, localOnly bool) (int, db.Error) { +func (r *relationshipDB) CountFollowRequests(ctx context.Context, accountID string, targetAccountID string) (int, db.Error) { q := r.conn. NewSelect(). - TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")) + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Column("follow_request.id"). + Order("follow_request.updated_at DESC") - if localOnly { - q = q. - Join("JOIN ? AS ? ON ? = ?", bun.Ident("accounts"), bun.Ident("account"), bun.Ident("follow.account_id"), bun.Ident("account.id")). - Where("? = ?", bun.Ident("follow.target_account_id"), accountID). - Where("? IS NULL", bun.Ident("account.domain")) - } else { - q = q.Where("? = ?", bun.Ident("follow.target_account_id"), accountID) + if accountID != "" { + q = q.Where("? = ?", bun.Ident("follow_request.account_id"), accountID) + } + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID) } return q.Count(ctx) } + +func (r *relationshipDB) Unfollow(ctx context.Context, originAccountID string, targetAccountID string) (string, db.Error) { + uri := new(string) + + _, err := r.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("follows"), bun.Ident("follow")). + Where("? = ?", bun.Ident("follow.target_account_id"), targetAccountID). + Where("? = ?", bun.Ident("follow.account_id"), originAccountID). + Returning("?", bun.Ident("uri")).Exec(ctx, uri) + + // Only return proper errors. + if err = r.conn.ProcessError(err); err != db.ErrNoEntries { + return *uri, err + } + + return *uri, nil +} + +func (r *relationshipDB) UnfollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (string, db.Error) { + uri := new(string) + + _, err := r.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("follow_requests"), bun.Ident("follow_request")). + Where("? = ?", bun.Ident("follow_request.target_account_id"), targetAccountID). + Where("? = ?", bun.Ident("follow_request.account_id"), originAccountID). + Returning("?", bun.Ident("uri")).Exec(ctx, uri) + + // Only return proper errors. + if err = r.conn.ProcessError(err); err != db.ErrNoEntries { + return *uri, err + } + + return *uri, nil +} diff --git a/internal/db/bundb/relationship_test.go b/internal/db/bundb/relationship_test.go index 04a04b088..3d307ecde 100644 --- a/internal/db/bundb/relationship_test.go +++ b/internal/db/bundb/relationship_test.go @@ -289,6 +289,47 @@ func (suite *RelationshipTestSuite) TestAcceptFollowRequestOK() { suite.FailNow(err.Error()) } + followRequestNotification := >smodel.Notification{ + ID: "01GV8MY1Q9KX2ZSWN4FAQ3V1PB", + OriginAccountID: account.ID, + TargetAccountID: targetAccount.ID, + NotificationType: gtsmodel.NotificationFollowRequest, + } + + if err := suite.db.Put(ctx, followRequestNotification); err != nil { + suite.FailNow(err.Error()) + } + + follow, err := suite.db.AcceptFollowRequest(ctx, account.ID, targetAccount.ID) + suite.NoError(err) + suite.NotNil(follow) + suite.Equal(followRequest.URI, follow.URI) + + // Ensure notification is deleted. + notification, err := suite.db.GetNotification(ctx, followRequestNotification.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(notification) +} + +func (suite *RelationshipTestSuite) TestAcceptFollowRequestNoNotification() { + ctx := context.Background() + account := suite.testAccounts["admin_account"] + targetAccount := suite.testAccounts["local_account_2"] + + followRequest := >smodel.FollowRequest{ + ID: "01GEF753FWHCHRDWR0QEHBXM8W", + URI: "http://localhost:8080/weeeeeeeeeeeeeeeee", + AccountID: account.ID, + TargetAccountID: targetAccount.ID, + } + + if err := suite.db.Put(ctx, followRequest); err != nil { + suite.FailNow(err.Error()) + } + + // Unlike the above test, don't create a notification. + // Follow request accept should still produce no error. + follow, err := suite.db.AcceptFollowRequest(ctx, account.ID, targetAccount.ID) suite.NoError(err) suite.NotNil(follow) @@ -352,9 +393,25 @@ func (suite *RelationshipTestSuite) TestRejectFollowRequestOK() { suite.FailNow(err.Error()) } + followRequestNotification := >smodel.Notification{ + ID: "01GV8MY1Q9KX2ZSWN4FAQ3V1PB", + OriginAccountID: account.ID, + TargetAccountID: targetAccount.ID, + NotificationType: gtsmodel.NotificationFollowRequest, + } + + if err := suite.db.Put(ctx, followRequestNotification); err != nil { + suite.FailNow(err.Error()) + } + rejectedFollowRequest, err := suite.db.RejectFollowRequest(ctx, account.ID, targetAccount.ID) suite.NoError(err) suite.NotNil(rejectedFollowRequest) + + // Ensure notification is deleted. + notification, err := suite.db.GetNotification(ctx, followRequestNotification.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(notification) } func (suite *RelationshipTestSuite) TestRejectFollowRequestNotExisting() { @@ -383,58 +440,92 @@ func (suite *RelationshipTestSuite) TestGetAccountFollowRequests() { suite.FailNow(err.Error()) } - followRequests, err := suite.db.GetAccountFollowRequests(ctx, targetAccount.ID) + followRequests, err := suite.db.GetFollowRequests(ctx, "", targetAccount.ID) suite.NoError(err) suite.Len(followRequests, 1) } func (suite *RelationshipTestSuite) TestGetAccountFollows() { account := suite.testAccounts["local_account_1"] - follows, err := suite.db.GetAccountFollows(context.Background(), account.ID) + follows, err := suite.db.GetFollows(context.Background(), account.ID, "") suite.NoError(err) suite.Len(follows, 2) } -func (suite *RelationshipTestSuite) TestCountAccountFollowsLocalOnly() { - account := suite.testAccounts["local_account_1"] - followsCount, err := suite.db.CountAccountFollows(context.Background(), account.ID, true) - suite.NoError(err) - suite.Equal(2, followsCount) -} - func (suite *RelationshipTestSuite) TestCountAccountFollows() { account := suite.testAccounts["local_account_1"] - followsCount, err := suite.db.CountAccountFollows(context.Background(), account.ID, false) + followsCount, err := suite.db.CountFollows(context.Background(), account.ID, "") suite.NoError(err) suite.Equal(2, followsCount) } func (suite *RelationshipTestSuite) TestGetAccountFollowedBy() { account := suite.testAccounts["local_account_1"] - follows, err := suite.db.GetAccountFollowedBy(context.Background(), account.ID, false) + follows, err := suite.db.GetFollows(context.Background(), "", account.ID) suite.NoError(err) suite.Len(follows, 2) } -func (suite *RelationshipTestSuite) TestGetAccountFollowedByLocalOnly() { +func (suite *RelationshipTestSuite) TestGetLocalFollowersIDs() { account := suite.testAccounts["local_account_1"] - follows, err := suite.db.GetAccountFollowedBy(context.Background(), account.ID, true) + accountIDs, err := suite.db.GetLocalFollowersIDs(context.Background(), account.ID) suite.NoError(err) - suite.Len(follows, 2) + suite.EqualValues([]string{"01F8MH5NBDF2MV7CTC4Q5128HF", "01F8MH17FWEB39HZJ76B6VXSKF"}, accountIDs) } func (suite *RelationshipTestSuite) TestCountAccountFollowedBy() { account := suite.testAccounts["local_account_1"] - followsCount, err := suite.db.CountAccountFollowedBy(context.Background(), account.ID, false) + followsCount, err := suite.db.CountFollows(context.Background(), "", account.ID) suite.NoError(err) suite.Equal(2, followsCount) } -func (suite *RelationshipTestSuite) TestCountAccountFollowedByLocalOnly() { - account := suite.testAccounts["local_account_1"] - followsCount, err := suite.db.CountAccountFollowedBy(context.Background(), account.ID, true) +func (suite *RelationshipTestSuite) TestUnfollowExisting() { + originAccount := suite.testAccounts["local_account_1"] + targetAccount := suite.testAccounts["admin_account"] + + uri, err := suite.db.Unfollow(context.Background(), originAccount.ID, targetAccount.ID) suite.NoError(err) - suite.Equal(2, followsCount) + suite.Equal("http://localhost:8080/users/the_mighty_zork/follow/01F8PY8RHWRQZV038T4E8T9YK8", uri) +} + +func (suite *RelationshipTestSuite) TestUnfollowNotExisting() { + originAccount := suite.testAccounts["local_account_1"] + targetAccountID := "01GTVD9N484CZ6AM90PGGNY7GQ" + + uri, err := suite.db.Unfollow(context.Background(), originAccount.ID, targetAccountID) + suite.NoError(err) + suite.Empty(uri) +} + +func (suite *RelationshipTestSuite) TestUnfollowRequestExisting() { + ctx := context.Background() + originAccount := suite.testAccounts["admin_account"] + targetAccount := suite.testAccounts["local_account_2"] + + followRequest := >smodel.FollowRequest{ + ID: "01GEF753FWHCHRDWR0QEHBXM8W", + URI: "http://localhost:8080/weeeeeeeeeeeeeeeee", + AccountID: originAccount.ID, + TargetAccountID: targetAccount.ID, + } + + if err := suite.db.Put(ctx, followRequest); err != nil { + suite.FailNow(err.Error()) + } + + uri, err := suite.db.UnfollowRequest(context.Background(), originAccount.ID, targetAccount.ID) + suite.NoError(err) + suite.Equal("http://localhost:8080/weeeeeeeeeeeeeeeee", uri) +} + +func (suite *RelationshipTestSuite) TestUnfollowRequestNotExisting() { + originAccount := suite.testAccounts["local_account_1"] + targetAccountID := "01GTVD9N484CZ6AM90PGGNY7GQ" + + uri, err := suite.db.UnfollowRequest(context.Background(), originAccount.ID, targetAccountID) + suite.NoError(err) + suite.Empty(uri) } func TestRelationshipTestSuite(t *testing.T) { diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go index 2ea4600e2..deec9a118 100644 --- a/internal/db/bundb/status.go +++ b/internal/db/bundb/status.go @@ -46,15 +46,6 @@ func (s *statusDB) newStatusQ(status interface{}) *bun.SelectQuery { Relation("CreatedWithApplication") } -func (s *statusDB) newFaveQ(faves interface{}) *bun.SelectQuery { - return s.conn. - NewSelect(). - Model(faves). - Relation("Account"). - Relation("TargetAccount"). - Relation("Status") -} - func (s *statusDB) GetStatusByID(ctx context.Context, id string) (*gtsmodel.Status, db.Error) { return s.getStatus( ctx, @@ -542,20 +533,6 @@ func (s *statusDB) IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.St return s.conn.Exists(ctx, q) } -func (s *statusDB) GetStatusFaves(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.StatusFave, db.Error) { - faves := []*gtsmodel.StatusFave{} - - q := s. - newFaveQ(&faves). - Where("? = ?", bun.Ident("status_fave.status_id"), status.ID) - - if err := q.Scan(ctx); err != nil { - return nil, s.conn.ProcessError(err) - } - - return faves, nil -} - func (s *statusDB) GetStatusReblogs(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.Status, db.Error) { reblogs := []*gtsmodel.Status{} diff --git a/internal/db/bundb/statusbookmark.go b/internal/db/bundb/statusbookmark.go new file mode 100644 index 000000000..92567b89a --- /dev/null +++ b/internal/db/bundb/statusbookmark.go @@ -0,0 +1,198 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package bundb + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/uptrace/bun" +) + +type statusBookmarkDB struct { + conn *DBConn + state *state.State +} + +func (s *statusBookmarkDB) GetStatusBookmark(ctx context.Context, id string) (*gtsmodel.StatusBookmark, db.Error) { + bookmark := new(gtsmodel.StatusBookmark) + + err := s.conn. + NewSelect(). + Model(bookmark). + Where("? = ?", bun.Ident("status_bookmark.ID"), id). + Scan(ctx) + if err != nil { + return nil, s.conn.ProcessError(err) + } + + bookmark.Account, err = s.state.DB.GetAccountByID(ctx, bookmark.AccountID) + if err != nil { + return nil, fmt.Errorf("error getting status bookmark account %q: %w", bookmark.AccountID, err) + } + + bookmark.TargetAccount, err = s.state.DB.GetAccountByID(ctx, bookmark.TargetAccountID) + if err != nil { + return nil, fmt.Errorf("error getting status bookmark target account %q: %w", bookmark.TargetAccountID, err) + } + + bookmark.Status, err = s.state.DB.GetStatusByID(ctx, bookmark.StatusID) + if err != nil { + return nil, fmt.Errorf("error getting status bookmark status %q: %w", bookmark.StatusID, err) + } + + return bookmark, nil +} + +func (s *statusBookmarkDB) GetStatusBookmarkID(ctx context.Context, accountID string, statusID string) (string, db.Error) { + var id string + + q := s.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Column("status_bookmark.id"). + Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID). + Where("? = ?", bun.Ident("status_bookmark.status_id"), statusID). + Limit(1) + + if err := q.Scan(ctx, &id); err != nil { + return "", s.conn.ProcessError(err) + } + + return id, nil +} + +func (s *statusBookmarkDB) GetStatusBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, db.Error) { + // Ensure reasonable + if limit < 0 { + limit = 0 + } + + // Guess size of IDs based on limit. + ids := make([]string, 0, limit) + + q := s.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Column("status_bookmark.id"). + Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID). + Order("status_bookmark.id DESC") + + if accountID == "" { + return nil, errors.New("must provide an account") + } + + if maxID != "" { + q = q.Where("? < ?", bun.Ident("status_bookmark.id"), maxID) + } + + if minID != "" { + q = q.Where("? > ?", bun.Ident("status_bookmark.id"), minID) + } + + if limit != 0 { + q = q.Limit(limit) + } + + if err := q.Scan(ctx, &ids); err != nil { + return nil, s.conn.ProcessError(err) + } + + bookmarks := make([]*gtsmodel.StatusBookmark, 0, len(ids)) + + for _, id := range ids { + bookmark, err := s.GetStatusBookmark(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting bookmark %q: %v", id, err) + continue + } + + bookmarks = append(bookmarks, bookmark) + } + + return bookmarks, nil +} + +func (s *statusBookmarkDB) PutStatusBookmark(ctx context.Context, statusBookmark *gtsmodel.StatusBookmark) db.Error { + _, err := s.conn. + NewInsert(). + Model(statusBookmark). + Exec(ctx) + + return s.conn.ProcessError(err) +} + +func (s *statusBookmarkDB) DeleteStatusBookmark(ctx context.Context, id string) db.Error { + _, err := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Where("? = ?", bun.Ident("status_bookmark.id"), id). + Exec(ctx) + + return s.conn.ProcessError(err) +} + +func (s *statusBookmarkDB) DeleteStatusBookmarks(ctx context.Context, targetAccountID string, originAccountID string) db.Error { + if targetAccountID == "" && originAccountID == "" { + return errors.New("DeleteBookmarks: one of targetAccountID or originAccountID must be set") + } + + // TODO: Capture bookmark IDs in a RETURNING + // statement (when bookmarks have a cache), + // + use the IDs to invalidate cache entries. + + q := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")) + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("status_bookmark.target_account_id"), targetAccountID) + } + + if originAccountID != "" { + q = q.Where("? = ?", bun.Ident("status_bookmark.account_id"), originAccountID) + } + + if _, err := q.Exec(ctx); err != nil { + return s.conn.ProcessError(err) + } + + return nil +} + +func (s *statusBookmarkDB) DeleteStatusBookmarksForStatus(ctx context.Context, statusID string) db.Error { + // TODO: Capture bookmark IDs in a RETURNING + // statement (when bookmarks have a cache), + // + use the IDs to invalidate cache entries. + + q := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")). + Where("? = ?", bun.Ident("status_bookmark.status_id"), statusID) + + if _, err := q.Exec(ctx); err != nil { + return s.conn.ProcessError(err) + } + + return nil +} diff --git a/internal/db/bundb/statusbookmark_test.go b/internal/db/bundb/statusbookmark_test.go new file mode 100644 index 000000000..35ed4a9da --- /dev/null +++ b/internal/db/bundb/statusbookmark_test.go @@ -0,0 +1,129 @@ +/* + GoToSocial + Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package bundb_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusBookmarkTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *StatusBookmarkTestSuite) TestGetStatusBookmarkIDOK() { + testBookmark := suite.testBookmarks["local_account_1_admin_account_status_1"] + + id, err := suite.db.GetStatusBookmarkID(context.Background(), testBookmark.AccountID, testBookmark.StatusID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(testBookmark.ID, id) +} + +func (suite *StatusBookmarkTestSuite) TestGetStatusBookmarkIDNonexisting() { + id, err := suite.db.GetStatusBookmarkID(context.Background(), "01GVAVGD06YJ2FSB5GJSMF8M2K", "01GVAVGKGR1MK9ZN7JCJFYSFZV") + suite.Empty(id) + suite.ErrorIs(err, db.ErrNoEntries) +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarksOriginatingFromAccount() { + testAccount := suite.testAccounts["local_account_1"] + + if err := suite.db.DeleteStatusBookmarks(context.Background(), "", testAccount.ID); err != nil { + suite.FailNow(err.Error()) + } + + bookmarks := []*gtsmodel.StatusBookmark{} + if err := suite.db.GetAll(context.Background(), &bookmarks); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range bookmarks { + if b.AccountID == testAccount.ID { + suite.FailNowf("", "no StatusBookmarks with account id %s should remain", testAccount.ID) + } + } +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarksTargetingAccount() { + testAccount := suite.testAccounts["local_account_1"] + + if err := suite.db.DeleteStatusBookmarks(context.Background(), testAccount.ID, ""); err != nil { + suite.FailNow(err.Error()) + } + + bookmarks := []*gtsmodel.StatusBookmark{} + if err := suite.db.GetAll(context.Background(), &bookmarks); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range bookmarks { + if b.TargetAccountID == testAccount.ID { + suite.FailNowf("", "no StatusBookmarks with target account id %s should remain", testAccount.ID) + } + } +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarksTargetingStatus() { + testStatus := suite.testStatuses["local_account_1_status_1"] + + if err := suite.db.DeleteStatusBookmarksForStatus(context.Background(), testStatus.ID); err != nil { + suite.FailNow(err.Error()) + } + + bookmarks := []*gtsmodel.StatusBookmark{} + if err := suite.db.GetAll(context.Background(), &bookmarks); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range bookmarks { + if b.StatusID == testStatus.ID { + suite.FailNowf("", "no StatusBookmarks with status id %s should remain", testStatus.ID) + } + } +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmark() { + testBookmark := suite.testBookmarks["local_account_1_admin_account_status_1"] + ctx := context.Background() + + if err := suite.db.DeleteStatusBookmark(ctx, testBookmark.ID); err != nil { + suite.FailNow(err.Error()) + } + + bookmark, err := suite.db.GetStatusBookmark(ctx, testBookmark.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(bookmark) +} + +func (suite *StatusBookmarkTestSuite) TestDeleteStatusBookmarkNonExisting() { + err := suite.db.DeleteStatusBookmark(context.Background(), "01GVAV715K6Y2SG9ZKS9ZA8G7G") + suite.NoError(err) +} + +func TestStatusBookmarkTestSuite(t *testing.T) { + suite.Run(t, new(StatusBookmarkTestSuite)) +} diff --git a/internal/db/bundb/statusfave.go b/internal/db/bundb/statusfave.go new file mode 100644 index 000000000..c42ab249f --- /dev/null +++ b/internal/db/bundb/statusfave.go @@ -0,0 +1,172 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package bundb + +import ( + "context" + "errors" + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/uptrace/bun" +) + +type statusFaveDB struct { + conn *DBConn + state *state.State +} + +func (s *statusFaveDB) GetStatusFave(ctx context.Context, id string) (*gtsmodel.StatusFave, db.Error) { + fave := new(gtsmodel.StatusFave) + + err := s.conn. + NewSelect(). + Model(fave). + Where("? = ?", bun.Ident("status_fave.ID"), id). + Scan(ctx) + if err != nil { + return nil, s.conn.ProcessError(err) + } + + fave.Account, err = s.state.DB.GetAccountByID(ctx, fave.AccountID) + if err != nil { + return nil, fmt.Errorf("error getting status fave account %q: %w", fave.AccountID, err) + } + + fave.TargetAccount, err = s.state.DB.GetAccountByID(ctx, fave.TargetAccountID) + if err != nil { + return nil, fmt.Errorf("error getting status fave target account %q: %w", fave.TargetAccountID, err) + } + + fave.Status, err = s.state.DB.GetStatusByID(ctx, fave.StatusID) + if err != nil { + return nil, fmt.Errorf("error getting status fave status %q: %w", fave.StatusID, err) + } + + return fave, nil +} + +func (s *statusFaveDB) GetStatusFaveByAccountID(ctx context.Context, accountID string, statusID string) (*gtsmodel.StatusFave, db.Error) { + var id string + + err := s.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")). + Column("status_fave.id"). + Where("? = ?", bun.Ident("status_fave.account_id"), accountID). + Where("? = ?", bun.Ident("status_fave.status_id"), statusID). + Scan(ctx, &id) + if err != nil { + return nil, s.conn.ProcessError(err) + } + + return s.GetStatusFave(ctx, id) +} + +func (s *statusFaveDB) GetStatusFaves(ctx context.Context, statusID string) ([]*gtsmodel.StatusFave, db.Error) { + ids := []string{} + + if err := s.conn. + NewSelect(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")). + Column("status_fave.id"). + Where("? = ?", bun.Ident("status_fave.status_id"), statusID). + Scan(ctx, &ids); err != nil { + return nil, s.conn.ProcessError(err) + } + + faves := make([]*gtsmodel.StatusFave, 0, len(ids)) + for _, id := range ids { + fave, err := s.GetStatusFave(ctx, id) + if err != nil { + log.Errorf(ctx, "error getting status fave %q: %v", id, err) + continue + } + + faves = append(faves, fave) + } + + return faves, nil +} + +func (s *statusFaveDB) PutStatusFave(ctx context.Context, statusFave *gtsmodel.StatusFave) db.Error { + _, err := s.conn. + NewInsert(). + Model(statusFave). + Exec(ctx) + + return s.conn.ProcessError(err) +} + +func (s *statusFaveDB) DeleteStatusFave(ctx context.Context, id string) db.Error { + _, err := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")). + Where("? = ?", bun.Ident("status_fave.id"), id). + Exec(ctx) + + return s.conn.ProcessError(err) +} + +func (s *statusFaveDB) DeleteStatusFaves(ctx context.Context, targetAccountID string, originAccountID string) db.Error { + if targetAccountID == "" && originAccountID == "" { + return errors.New("DeleteStatusFaves: one of targetAccountID or originAccountID must be set") + } + + // TODO: Capture fave IDs in a RETURNING + // statement (when faves have a cache), + // + use the IDs to invalidate cache entries. + + q := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")) + + if targetAccountID != "" { + q = q.Where("? = ?", bun.Ident("status_fave.target_account_id"), targetAccountID) + } + + if originAccountID != "" { + q = q.Where("? = ?", bun.Ident("status_fave.account_id"), originAccountID) + } + + if _, err := q.Exec(ctx); err != nil { + return s.conn.ProcessError(err) + } + + return nil +} + +func (s *statusFaveDB) DeleteStatusFavesForStatus(ctx context.Context, statusID string) db.Error { + // TODO: Capture fave IDs in a RETURNING + // statement (when faves have a cache), + // + use the IDs to invalidate cache entries. + + q := s.conn. + NewDelete(). + TableExpr("? AS ?", bun.Ident("status_faves"), bun.Ident("status_fave")). + Where("? = ?", bun.Ident("status_fave.status_id"), statusID) + + if _, err := q.Exec(ctx); err != nil { + return s.conn.ProcessError(err) + } + + return nil +} diff --git a/internal/db/bundb/statusfave_test.go b/internal/db/bundb/statusfave_test.go new file mode 100644 index 000000000..98e495bf3 --- /dev/null +++ b/internal/db/bundb/statusfave_test.go @@ -0,0 +1,148 @@ +/* + GoToSocial + Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package bundb_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusFaveTestSuite struct { + BunDBStandardTestSuite +} + +func (suite *StatusFaveTestSuite) TestGetStatusFaves() { + testStatus := suite.testStatuses["admin_account_status_1"] + + faves, err := suite.db.GetStatusFaves(context.Background(), testStatus.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.NotEmpty(faves) + for _, fave := range faves { + suite.NotNil(fave.Account) + suite.NotNil(fave.TargetAccount) + suite.NotNil(fave.Status) + } +} + +func (suite *StatusFaveTestSuite) TestGetStatusFavesNone() { + testStatus := suite.testStatuses["admin_account_status_4"] + + faves, err := suite.db.GetStatusFaves(context.Background(), testStatus.ID) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.Empty(faves) +} + +func (suite *StatusFaveTestSuite) TestGetStatusFaveByAccountID() { + testAccount := suite.testAccounts["local_account_1"] + testStatus := suite.testStatuses["admin_account_status_1"] + + fave, err := suite.db.GetStatusFaveByAccountID(context.Background(), testAccount.ID, testStatus.ID) + suite.NoError(err) + suite.NotNil(fave) +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFavesOriginatingFromAccount() { + testAccount := suite.testAccounts["local_account_1"] + + if err := suite.db.DeleteStatusFaves(context.Background(), "", testAccount.ID); err != nil { + suite.FailNow(err.Error()) + } + + faves := []*gtsmodel.StatusFave{} + if err := suite.db.GetAll(context.Background(), &faves); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range faves { + if b.AccountID == testAccount.ID { + suite.FailNowf("", "no StatusFaves with account id %s should remain", testAccount.ID) + } + } +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFavesTargetingAccount() { + testAccount := suite.testAccounts["local_account_1"] + + if err := suite.db.DeleteStatusFaves(context.Background(), testAccount.ID, ""); err != nil { + suite.FailNow(err.Error()) + } + + faves := []*gtsmodel.StatusFave{} + if err := suite.db.GetAll(context.Background(), &faves); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range faves { + if b.TargetAccountID == testAccount.ID { + suite.FailNowf("", "no StatusFaves with target account id %s should remain", testAccount.ID) + } + } +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFavesTargetingStatus() { + testStatus := suite.testStatuses["local_account_1_status_1"] + + if err := suite.db.DeleteStatusFavesForStatus(context.Background(), testStatus.ID); err != nil { + suite.FailNow(err.Error()) + } + + faves := []*gtsmodel.StatusFave{} + if err := suite.db.GetAll(context.Background(), &faves); err != nil && !errors.Is(err, db.ErrNoEntries) { + suite.FailNow(err.Error()) + } + + for _, b := range faves { + if b.StatusID == testStatus.ID { + suite.FailNowf("", "no StatusFaves with status id %s should remain", testStatus.ID) + } + } +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFave() { + testFave := suite.testFaves["local_account_1_admin_account_status_1"] + ctx := context.Background() + + if err := suite.db.DeleteStatusFave(ctx, testFave.ID); err != nil { + suite.FailNow(err.Error()) + } + + fave, err := suite.db.GetStatusFave(ctx, testFave.ID) + suite.ErrorIs(err, db.ErrNoEntries) + suite.Nil(fave) +} + +func (suite *StatusFaveTestSuite) TestDeleteStatusFaveNonExisting() { + err := suite.db.DeleteStatusFave(context.Background(), "01GVAV715K6Y2SG9ZKS9ZA8G7G") + suite.NoError(err) +} + +func TestStatusFaveTestSuite(t *testing.T) { + suite.Run(t, new(StatusFaveTestSuite)) +} diff --git a/internal/db/db.go b/internal/db/db.go index c4a283395..7b25b3dae 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -43,6 +43,8 @@ type DB interface { Report Session Status + StatusBookmark + StatusFave Timeline User Tombstone diff --git a/internal/db/notification.go b/internal/db/notification.go index 19f0f199d..18e40b4c1 100644 --- a/internal/db/notification.go +++ b/internal/db/notification.go @@ -29,8 +29,31 @@ type Notification interface { // // Returned notifications will be ordered ID descending (ie., highest/newest to lowest/oldest). GetNotifications(ctx context.Context, accountID string, excludeTypes []string, limit int, maxID string, sinceID string) ([]*gtsmodel.Notification, Error) + // GetNotification returns one notification according to its id. GetNotification(ctx context.Context, id string) (*gtsmodel.Notification, Error) - // ClearNotifications deletes every notification that pertain to the given accountID. - ClearNotifications(ctx context.Context, accountID string) Error + + // DeleteNotification deletes one notification according to its id, + // and removes that notification from the in-memory cache. + DeleteNotification(ctx context.Context, id string) Error + + // DeleteNotifications mass deletes notifications targeting targetAccountID + // and/or originating from originAccountID. + // + // If targetAccountID is set and originAccountID isn't, all notifications + // that target the given account will be deleted. + // + // If originAccountID is set and targetAccountID isn't, all notifications + // originating from the given account will be deleted. + // + // If both are set, then notifications that target targetAccountID and + // originate from originAccountID will be deleted. + // + // At least one parameter must not be an empty string. + DeleteNotifications(ctx context.Context, targetAccountID string, originAccountID string) Error + + // DeleteNotificationsForStatus deletes all notifications that relate to + // the given statusID. This function is useful when a status has been deleted, + // and so notifications relating to that status must also be deleted. + DeleteNotificationsForStatus(ctx context.Context, statusID string) Error } diff --git a/internal/db/relationship.go b/internal/db/relationship.go index 643a5696a..d13a73dea 100644 --- a/internal/db/relationship.go +++ b/internal/db/relationship.go @@ -73,22 +73,61 @@ type Relationship interface { // The deleted follow request will be returned so that further processing can be done on it. RejectFollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (*gtsmodel.FollowRequest, Error) - // GetAccountFollowRequests returns all follow requests targeting the given account. - GetAccountFollowRequests(ctx context.Context, accountID string) ([]*gtsmodel.FollowRequest, Error) + // GetFollows returns a slice of follows owned by the given accountID, and/or + // targeting the given account id. + // + // If accountID is set and targetAccountID isn't, then all follows created by + // accountID will be returned. + // + // If targetAccountID is set and accountID isn't, then all follows targeting + // targetAccountID will be returned. + // + // If both accountID and targetAccountID are set, then only 0 or 1 follows will + // be in the returned slice. + GetFollows(ctx context.Context, accountID string, targetAccountID string) ([]*gtsmodel.Follow, Error) - // GetAccountFollows returns a slice of follows owned by the given accountID. - GetAccountFollows(ctx context.Context, accountID string) ([]*gtsmodel.Follow, Error) + // GetLocalFollowersIDs returns a list of local account IDs which follow the + // targetAccountID. The returned IDs are not guaranteed to be ordered in any + // particular way, so take care. + GetLocalFollowersIDs(ctx context.Context, targetAccountID string) ([]string, Error) - // CountAccountFollows returns the amount of accounts that the given accountID is following. + // CountFollows is like GetFollows, but just counts rather than returning. + CountFollows(ctx context.Context, accountID string, targetAccountID string) (int, Error) + + // GetFollowRequests returns a slice of follows requests owned by the given + // accountID, and/or targeting the given account id. + // + // If accountID is set and targetAccountID isn't, then all requests created by + // accountID will be returned. // - // If localOnly is set to true, then only follows from *this instance* will be returned. - CountAccountFollows(ctx context.Context, accountID string, localOnly bool) (int, Error) + // If targetAccountID is set and accountID isn't, then all requests targeting + // targetAccountID will be returned. + // + // If both accountID and targetAccountID are set, then only 0 or 1 requests will + // be in the returned slice. + GetFollowRequests(ctx context.Context, accountID string, targetAccountID string) ([]*gtsmodel.FollowRequest, Error) + + // CountFollowRequests is like GetFollowRequests, but just counts rather than returning. + CountFollowRequests(ctx context.Context, accountID string, targetAccountID string) (int, Error) - // GetAccountFollowedBy fetches follows that target given accountID. + // Unfollow removes a follow targeting targetAccountID and originating + // from originAccountID. + // + // If a follow was removed this way, the AP URI of the follow will be + // returned to the caller, so that further processing can take place + // if necessary. // - // If localOnly is set to true, then only follows from *this instance* will be returned. - GetAccountFollowedBy(ctx context.Context, accountID string, localOnly bool) ([]*gtsmodel.Follow, Error) + // If no follow was removed this way, the returned string will be empty. + Unfollow(ctx context.Context, originAccountID string, targetAccountID string) (string, Error) - // CountAccountFollowedBy returns the amounts that the given ID is followed by. - CountAccountFollowedBy(ctx context.Context, accountID string, localOnly bool) (int, Error) + // UnfollowRequest removes a follow request targeting targetAccountID + // and originating from originAccountID. + // + // If a follow request was removed this way, the AP URI of the follow + // request will be returned to the caller, so that further processing + // can take place if necessary. + // + // If no follow request was removed this way, the returned string will + // be empty. + UnfollowRequest(ctx context.Context, originAccountID string, targetAccountID string) (string, Error) } diff --git a/internal/db/status.go b/internal/db/status.go index 7744570b2..16728983a 100644 --- a/internal/db/status.go +++ b/internal/db/status.go @@ -77,10 +77,6 @@ type Status interface { // IsStatusBookmarkedBy checks if a given status has been bookmarked by a given account ID IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.Status, accountID string) (bool, Error) - // GetStatusFaves returns a slice of faves/likes of the given status. - // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. - GetStatusFaves(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.StatusFave, Error) - // GetStatusReblogs returns a slice of statuses that are a boost/reblog of the given status. // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. GetStatusReblogs(ctx context.Context, status *gtsmodel.Status) ([]*gtsmodel.Status, Error) diff --git a/internal/db/statusbookmark.go b/internal/db/statusbookmark.go new file mode 100644 index 000000000..e47bfc54d --- /dev/null +++ b/internal/db/statusbookmark.go @@ -0,0 +1,66 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package db + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusBookmark interface { + // GetStatusBookmark gets one status bookmark with the given ID. + GetStatusBookmark(ctx context.Context, id string) (*gtsmodel.StatusBookmark, Error) + + // GetStatusBookmarkID is a shortcut function for returning just the database ID + // of a status bookmark created by the given accountID, targeting the given statusID. + GetStatusBookmarkID(ctx context.Context, accountID string, statusID string) (string, Error) + + // GetStatusBookmarks retrieves status bookmarks created by the given accountID, + // and using the provided parameters. If limit is < 0 then no limit will be set. + // + // This function is primarily useful for paging through bookmarks in a sort of + // timeline view. + GetStatusBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, Error) + + // PutStatusBookmark inserts the given statusBookmark into the database. + PutStatusBookmark(ctx context.Context, statusBookmark *gtsmodel.StatusBookmark) Error + + // DeleteStatusBookmark deletes one status bookmark with the given ID. + DeleteStatusBookmark(ctx context.Context, id string) Error + + // DeleteStatusBookmarks mass deletes status bookmarks targeting targetAccountID + // and/or originating from originAccountID and/or bookmarking statusID. + // + // If targetAccountID is set and originAccountID isn't, all status bookmarks + // that target the given account will be deleted. + // + // If originAccountID is set and targetAccountID isn't, all status bookmarks + // originating from the given account will be deleted. + // + // If both are set, then status bookmarks that target targetAccountID and + // originate from originAccountID will be deleted. + // + // At least one parameter must not be an empty string. + DeleteStatusBookmarks(ctx context.Context, targetAccountID string, originAccountID string) Error + + // DeleteStatusBookmarksForStatus deletes all status bookmarks that target the + // given status ID. This is useful when a status has been deleted, and you need + // to clean up after it. + DeleteStatusBookmarksForStatus(ctx context.Context, statusID string) Error +} diff --git a/internal/db/statusfave.go b/internal/db/statusfave.go new file mode 100644 index 000000000..2d55592aa --- /dev/null +++ b/internal/db/statusfave.go @@ -0,0 +1,63 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package db + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +type StatusFave interface { + // GetStatusFave returns one status fave with the given id. + GetStatusFave(ctx context.Context, id string) (*gtsmodel.StatusFave, Error) + + // GetStatusFaveByAccountID gets one status fave created by the given + // accountID, targeting the given statusID. + GetStatusFaveByAccountID(ctx context.Context, accountID string, statusID string) (*gtsmodel.StatusFave, Error) + + // GetStatusFaves returns a slice of faves/likes of the given status. + // This slice will be unfiltered, not taking account of blocks and whatnot, so filter it before serving it back to a user. + GetStatusFaves(ctx context.Context, statusID string) ([]*gtsmodel.StatusFave, Error) + + // PutStatusFave inserts the given statusFave into the database. + PutStatusFave(ctx context.Context, statusFave *gtsmodel.StatusFave) Error + + // DeleteStatusFave deletes one status fave with the given id. + DeleteStatusFave(ctx context.Context, id string) Error + + // DeleteStatusFaves mass deletes status faves targeting targetAccountID + // and/or originating from originAccountID and/or faving statusID. + // + // If targetAccountID is set and originAccountID isn't, all status faves + // that target the given account will be deleted. + // + // If originAccountID is set and targetAccountID isn't, all status faves + // originating from the given account will be deleted. + // + // If both are set, then status faves that target targetAccountID and + // originate from originAccountID will be deleted. + // + // At least one parameter must not be an empty string. + DeleteStatusFaves(ctx context.Context, targetAccountID string, originAccountID string) Error + + // DeleteStatusFavesForStatus deletes all status faves that target the + // given status ID. This is useful when a status has been deleted, and you need + // to clean up after it. + DeleteStatusFavesForStatus(ctx context.Context, statusID string) Error +} |