gotosocial/internal/db/bundb/interaction.go
tobi f23f04e0b1
[feature] Interaction requests client api + settings panel (#3215)
* [feature] Interaction requests client api + settings panel

* test accept / reject

* fmt

* don't pin rejected interaction

* use single db model for interaction accept, reject, and request

* swaggor

* env sharting

* append errors

* remove ErrNoEntries checks

* change intReqID to reqID

* rename "pend" to "request"

* markIntsPending -> mark interactionsPending

* use log instead of returning error when rejecting interaction

* empty migration

* jolly renaming

* make interactionURI unique again

* swag grr

* remove unnecessary locks

* invalidate as last step
2024-08-24 11:49:37 +02:00

348 lines
9.5 KiB
Go

// 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"
"slices"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/uptrace/bun"
)
type interactionDB struct {
db *bun.DB
state *state.State
}
func (i *interactionDB) newInteractionRequestQ(request interface{}) *bun.SelectQuery {
return i.db.
NewSelect().
Model(request)
}
func (i *interactionDB) GetInteractionRequestByID(ctx context.Context, id string) (*gtsmodel.InteractionRequest, error) {
return i.getInteractionRequest(
ctx,
"ID",
func(request *gtsmodel.InteractionRequest) error {
return i.
newInteractionRequestQ(request).
Where("? = ?", bun.Ident("interaction_request.id"), id).
Scan(ctx)
},
id,
)
}
func (i *interactionDB) GetInteractionRequestByInteractionURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) {
return i.getInteractionRequest(
ctx,
"InteractionURI",
func(request *gtsmodel.InteractionRequest) error {
return i.
newInteractionRequestQ(request).
Where("? = ?", bun.Ident("interaction_request.interaction_uri"), uri).
Scan(ctx)
},
uri,
)
}
func (i *interactionDB) GetInteractionRequestByURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) {
return i.getInteractionRequest(
ctx,
"URI",
func(request *gtsmodel.InteractionRequest) error {
return i.
newInteractionRequestQ(request).
Where("? = ?", bun.Ident("interaction_request.uri"), uri).
Scan(ctx)
},
uri,
)
}
func (i *interactionDB) getInteractionRequest(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.InteractionRequest) error,
keyParts ...any,
) (*gtsmodel.InteractionRequest, error) {
// Fetch request from database cache with loader callback
request, err := i.state.Caches.DB.InteractionRequest.LoadOne(lookup, func() (*gtsmodel.InteractionRequest, error) {
var request gtsmodel.InteractionRequest
// Not cached! Perform database query
if err := dbQuery(&request); err != nil {
return nil, err
}
return &request, nil
}, keyParts...)
if err != nil {
// Error already processed.
return nil, err
}
if gtscontext.Barebones(ctx) {
// Only a barebones model was requested.
return request, nil
}
if err := i.PopulateInteractionRequest(ctx, request); err != nil {
return nil, err
}
return request, nil
}
func (i *interactionDB) PopulateInteractionRequest(ctx context.Context, req *gtsmodel.InteractionRequest) error {
var (
err error
errs = gtserror.NewMultiError(4)
)
if req.Status == nil {
// Target status is not set, fetch from the database.
req.Status, err = i.state.DB.GetStatusByID(
gtscontext.SetBarebones(ctx),
req.StatusID,
)
if err != nil {
errs.Appendf("error populating interactionRequest target: %w", err)
}
}
if req.TargetAccount == nil {
// Target account is not set, fetch from the database.
req.TargetAccount, err = i.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
req.TargetAccountID,
)
if err != nil {
errs.Appendf("error populating interactionRequest target account: %w", err)
}
}
if req.InteractingAccount == nil {
// InteractingAccount is not set, fetch from the database.
req.InteractingAccount, err = i.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
req.InteractingAccountID,
)
if err != nil {
errs.Appendf("error populating interactionRequest interacting account: %w", err)
}
}
// Depending on the interaction type, *try* to populate
// the related model, but don't error if this is not
// possible, as it may have just already been deleted
// by its owner and we haven't cleaned up yet.
switch req.InteractionType {
case gtsmodel.InteractionLike:
req.Like, err = i.state.DB.GetStatusFaveByURI(ctx, req.InteractionURI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("error populating interactionRequest Like: %w", err)
}
case gtsmodel.InteractionReply:
req.Reply, err = i.state.DB.GetStatusByURI(ctx, req.InteractionURI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("error populating interactionRequest Reply: %w", err)
}
case gtsmodel.InteractionAnnounce:
req.Announce, err = i.state.DB.GetStatusByURI(ctx, req.InteractionURI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("error populating interactionRequest Announce: %w", err)
}
}
return errs.Combine()
}
func (i *interactionDB) PutInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error {
return i.state.Caches.DB.InteractionRequest.Store(request, func() error {
_, err := i.db.NewInsert().Model(request).Exec(ctx)
return err
})
}
func (i *interactionDB) UpdateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest, columns ...string) error {
return i.state.Caches.DB.InteractionRequest.Store(request, func() error {
_, err := i.db.
NewUpdate().
Model(request).
Where("? = ?", bun.Ident("interaction_request.id"), request.ID).
Column(columns...).
Exec(ctx)
return err
})
}
func (i *interactionDB) DeleteInteractionRequestByID(ctx context.Context, id string) error {
defer i.state.Caches.DB.InteractionRequest.Invalidate("ID", id)
_, err := i.db.NewDelete().
TableExpr("? AS ?", bun.Ident("interaction_requests"), bun.Ident("interaction_request")).
Where("? = ?", bun.Ident("interaction_request.id"), id).
Exec(ctx)
return err
}
func (i *interactionDB) GetInteractionsRequestsForAcct(
ctx context.Context,
acctID string,
statusID string,
likes bool,
replies bool,
boosts bool,
page *paging.Page,
) ([]*gtsmodel.InteractionRequest, error) {
if !likes && !replies && !boosts {
return nil, gtserror.New("at least one of likes, replies, or boosts must be true")
}
var (
// Get paging params.
minID = page.GetMin()
maxID = page.GetMax()
limit = page.GetLimit()
order = page.GetOrder()
// Make educated guess for slice size
reqIDs = make([]string, 0, limit)
)
// Create the basic select query.
q := i.db.
NewSelect().
Column("id").
TableExpr(
"? AS ?",
bun.Ident("interaction_requests"),
bun.Ident("interaction_request"),
).
// Select only interaction requests that
// are neither accepted or rejected yet,
// ie., without an Accept or Reject URI.
Where("? IS NULL", bun.Ident("uri"))
// Select interactions targeting status.
if statusID != "" {
q = q.Where("? = ?", bun.Ident("status_id"), statusID)
}
// Select interactions targeting account.
if acctID != "" {
q = q.Where("? = ?", bun.Ident("target_account_id"), acctID)
}
// Figure out which types of interaction are
// being sought, and add them to the query.
wantTypes := make([]gtsmodel.InteractionType, 0, 3)
if likes {
wantTypes = append(wantTypes, gtsmodel.InteractionLike)
}
if replies {
wantTypes = append(wantTypes, gtsmodel.InteractionReply)
}
if boosts {
wantTypes = append(wantTypes, gtsmodel.InteractionAnnounce)
}
q = q.Where("? IN (?)", bun.Ident("interaction_type"), bun.In(wantTypes))
// Add paging param max ID.
if maxID != "" {
q = q.Where("? < ?", bun.Ident("id"), maxID)
}
// Add paging param min ID.
if minID != "" {
q = q.Where("? > ?", bun.Ident("id"), minID)
}
// Add paging param order.
if order == paging.OrderAscending {
// Page up.
q = q.OrderExpr("? ASC", bun.Ident("id"))
} else {
// Page down.
q = q.OrderExpr("? DESC", bun.Ident("id"))
}
// Add paging param limit.
if limit > 0 {
q = q.Limit(limit)
}
// Execute the query and scan into IDs.
err := q.Scan(ctx, &reqIDs)
if err != nil {
return nil, err
}
// Catch case of no items early
if len(reqIDs) == 0 {
return nil, db.ErrNoEntries
}
// If we're paging up, we still want interactions
// to be sorted by ID desc, so reverse ids slice.
if order == paging.OrderAscending {
slices.Reverse(reqIDs)
}
// For each interaction request ID,
// select the interaction request.
reqs := make([]*gtsmodel.InteractionRequest, 0, len(reqIDs))
for _, id := range reqIDs {
req, err := i.GetInteractionRequestByID(ctx, id)
if err != nil {
return nil, err
}
reqs = append(reqs, req)
}
return reqs, nil
}
func (i *interactionDB) IsInteractionRejected(ctx context.Context, interactionURI string) (bool, error) {
req, err := i.GetInteractionRequestByInteractionURI(ctx, interactionURI)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return false, gtserror.Newf("db error getting interaction request: %w", err)
}
if req == nil {
// No interaction req at all with this
// interactionURI so it can't be rejected.
return false, nil
}
return req.IsRejected(), nil
}