mirror of https://gitlab.com/ngerakines/tavern.git
376 lines
18 KiB
Go
376 lines
18 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofrs/uuid"
|
|
|
|
"github.com/ngerakines/tavern/common"
|
|
"github.com/ngerakines/tavern/errors"
|
|
)
|
|
|
|
type GroupStorage interface {
|
|
CountGroupMembers(ctx context.Context, groupActorRowID uuid.UUID) (int, error)
|
|
GetGroupByID(ctx context.Context, groupRowID uuid.UUID) (Group, error)
|
|
GetGroupByName(ctx context.Context, name string) (Group, error)
|
|
GetGroupsByOwner(ctx context.Context, userRowID uuid.UUID) ([]Group, error)
|
|
GroupActorsForMemberActorRowID(ctx context.Context, groupActorRowID uuid.UUID) ([]*Actor, error)
|
|
GroupMemberActorIDs(ctx context.Context, groupActorRowID uuid.UUID) ([]string, error)
|
|
GroupMemberActorsForGroupActorID(ctx context.Context, groupActorRowID uuid.UUID) ([]*Actor, error)
|
|
GroupMemberCanInvite(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (bool, error)
|
|
GroupMemberCanSubmit(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (bool, error)
|
|
GroupNameAvailable(context.Context, string) (bool, error)
|
|
IsActorInGroup(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (bool, error)
|
|
IsActorInvitedToGroup(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (bool, error)
|
|
ListGroups(ctx context.Context) ([]Group, error)
|
|
MinutesSinceGroupBoost(ctx context.Context, groupActorRowID, objectRowID uuid.UUID) (int, error)
|
|
RecordGroup(ctx context.Context, userRowID, actorRowID uuid.UUID, name, publicKey, privateKey, displayName, about string) (uuid.UUID, error)
|
|
RecordGroupAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, userRowID, actorRowID uuid.UUID, name, publicKey, privateKey, displayName, about string) (uuid.UUID, error)
|
|
RecordGroupBoost(ctx context.Context, groupActorRowID, objectRowID uuid.UUID) (uuid.UUID, error)
|
|
RecordGroupBoostAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, groupActorRowID, objectRowID uuid.UUID) (uuid.UUID, error)
|
|
RecordGroupInvitation(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (uuid.UUID, error)
|
|
RecordGroupInvitationAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, groupActorRowID, memberActorRowID uuid.UUID) (uuid.UUID, error)
|
|
RecordGroupMember(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, activity Payload, status RelationshipStatus, role GroupRole) (uuid.UUID, error)
|
|
RecordGroupMemberAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, groupActorRowID, memberActorRowID uuid.UUID, activity Payload, status RelationshipStatus, role GroupRole) (uuid.UUID, error)
|
|
RemoveGroupInvitation(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) error
|
|
RemoveGroupMember(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) error
|
|
UpdateGroupAbout(ctx context.Context, groupRowID uuid.UUID, about string) error
|
|
UpdateGroupAcceptFollowers(ctx context.Context, groupRowID uuid.UUID, acceptFollowers bool) error
|
|
UpdateGroupAllowRemote(ctx context.Context, groupRowID uuid.UUID, allowRemote bool) error
|
|
UpdateGroupDefaultRole(ctx context.Context, groupActorRowID uuid.UUID, role GroupRole) error
|
|
UpdateGroupDirectoryOptIn(ctx context.Context, groupRowID uuid.UUID, value bool) error
|
|
UpdateGroupMemberRole(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, role GroupRole) error
|
|
UpdateGroupMemberStatus(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, status RelationshipStatus) error
|
|
ListGroupsPaginated(ctx context.Context, limit, offset int) ([]*Actor, error)
|
|
CountGroupsMembers(ctx context.Context, groupActorRowIDs []uuid.UUID) ([]Count, error)
|
|
}
|
|
|
|
type Group struct {
|
|
ID uuid.UUID
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Name string
|
|
PublicKey string
|
|
PrivateKey string
|
|
DisplayName string
|
|
About string
|
|
AcceptFollowers bool
|
|
DefaultMemberRole GroupRole
|
|
AllowRemote bool
|
|
DirectoryOptIn bool
|
|
OwnerID uuid.UUID
|
|
ActorID uuid.UUID
|
|
}
|
|
|
|
type GroupRole int16
|
|
|
|
const (
|
|
// GroupOwner can manage the group.
|
|
GroupOwner GroupRole = 2
|
|
// GroupMember can post to the group.
|
|
GroupMember GroupRole = 1
|
|
// GroupViewer can receive group activities, but cannot create group activities.
|
|
GroupViewer GroupRole = 0
|
|
)
|
|
|
|
var groupFields = []string{
|
|
"id",
|
|
"created_at",
|
|
"updated_at",
|
|
"name",
|
|
"public_key",
|
|
"private_key",
|
|
"display_name",
|
|
"about",
|
|
"accept_followers",
|
|
"default_member_role",
|
|
"allow_remote",
|
|
"directory_opt_in",
|
|
"owner",
|
|
"actor_id",
|
|
}
|
|
|
|
func (g Group) GetPrivateKey() (*rsa.PrivateKey, error) {
|
|
block, _ := pem.Decode([]byte(g.PrivateKey))
|
|
if block == nil {
|
|
return nil, errors.New("invalid RSA PEM")
|
|
}
|
|
|
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
func (g Group) GetDecodedPublicKey() (*rsa.PublicKey, error) {
|
|
return DecodePublicKey(g.PublicKey)
|
|
}
|
|
|
|
func (s pgStorage) RecordGroup(ctx context.Context, userRowID, actorRowID uuid.UUID, name, publicKey, privateKey, displayName, about string) (uuid.UUID, error) {
|
|
rowID := NewV4()
|
|
now := s.now()
|
|
return s.RecordGroupAll(ctx, rowID, now, now, userRowID, actorRowID, name, publicKey, privateKey, displayName, about)
|
|
}
|
|
|
|
func (s pgStorage) RecordGroupAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, userRowID, actorRowID uuid.UUID, name, publicKey, privateKey, displayName, about string) (uuid.UUID, error) {
|
|
query := `INSERT INTO groups (id, created_at, updated_at, name, public_key, private_key, display_name, about, owner, actor_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT ON CONSTRAINT groups_name_uindex DO UPDATE set updated_at = $3 RETURNING id`
|
|
var id uuid.UUID
|
|
err := s.db.QueryRowContext(ctx, query, rowID, createdAt, updatedAt, name, publicKey, privateKey, displayName, about, userRowID, actorRowID).Scan(&id)
|
|
return id, errors.WrapGroupUpsertFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) RecordGroupMember(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, activity Payload, status RelationshipStatus, role GroupRole) (uuid.UUID, error) {
|
|
rowID := NewV4()
|
|
now := s.now()
|
|
return s.RecordGroupMemberAll(ctx, rowID, now, now, groupActorRowID, memberActorRowID, activity, status, role)
|
|
}
|
|
|
|
func (s pgStorage) RecordGroupMemberAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, groupActorRowID, memberActorRowID uuid.UUID, activity Payload, status RelationshipStatus, role GroupRole) (uuid.UUID, error) {
|
|
query := `INSERT INTO group_members (id, created_at, updated_at, group_actor_id, member_actor_id, activity, relationship_status, member_role) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT ON CONSTRAINT group_members_uindex DO UPDATE set updated_at = $3 RETURNING id`
|
|
var id uuid.UUID
|
|
err := s.db.QueryRowContext(ctx, query, rowID, createdAt, updatedAt, groupActorRowID, memberActorRowID, activity, status, role).Scan(&id)
|
|
return id, errors.WrapGroupMemberUpsertFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) GetGroupByName(ctx context.Context, name string) (Group, error) {
|
|
query := fmt.Sprintf("SELECT %s FROM groups WHERE name = $1", strings.Join(groupFields, ", "))
|
|
return s.getFirstGroup(ctx, query, name)
|
|
}
|
|
|
|
func (s pgStorage) GetGroupByID(ctx context.Context, groupRowID uuid.UUID) (Group, error) {
|
|
query := fmt.Sprintf("SELECT %s FROM groups WHERE id = $1", strings.Join(groupFields, ", "))
|
|
return s.getFirstGroup(ctx, query, groupRowID)
|
|
}
|
|
|
|
func (s pgStorage) GetGroupsByOwner(ctx context.Context, userRowID uuid.UUID) ([]Group, error) {
|
|
query := fmt.Sprintf("SELECT %s FROM groups WHERE owner = $1", strings.Join(groupFields, ", "))
|
|
return s.getGroups(ctx, query, userRowID)
|
|
}
|
|
|
|
func (s pgStorage) ListGroups(ctx context.Context) ([]Group, error) {
|
|
query := fmt.Sprintf("SELECT %s FROM groups ORDER BY name ASC", strings.Join(groupFields, ", "))
|
|
return s.getGroups(ctx, query)
|
|
}
|
|
|
|
func (s pgStorage) getFirstGroup(ctx context.Context, query string, args ...interface{}) (Group, error) {
|
|
groups, err := s.getGroups(ctx, query, args...)
|
|
if err != nil {
|
|
return Group{}, err
|
|
}
|
|
if len(groups) == 0 {
|
|
return Group{}, errors.NewGroupNotFoundError(nil)
|
|
}
|
|
return groups[0], nil
|
|
}
|
|
|
|
func (s pgStorage) getGroups(ctx context.Context, query string, args ...interface{}) ([]Group, error) {
|
|
results := make([]Group, 0)
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, errors.NewGroupSelectFailedError(err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var group Group
|
|
err = rows.Scan(&group.ID,
|
|
&group.CreatedAt,
|
|
&group.UpdatedAt,
|
|
&group.Name,
|
|
&group.PublicKey,
|
|
&group.PrivateKey,
|
|
&group.DisplayName,
|
|
&group.About,
|
|
&group.AcceptFollowers,
|
|
&group.DefaultMemberRole,
|
|
&group.AllowRemote,
|
|
&group.DirectoryOptIn,
|
|
&group.OwnerID,
|
|
&group.ActorID)
|
|
if err != nil {
|
|
return nil, errors.NewInvalidGroupError(err)
|
|
}
|
|
|
|
results = append(results, group)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func (s pgStorage) GroupNameAvailable(ctx context.Context, name string) (bool, error) {
|
|
queries := []string{
|
|
`SELECT COUNT(*) FROM users WHERE name = $1`,
|
|
`SELECT COUNT(*) FROM groups WHERE name = $1`,
|
|
}
|
|
for _, query := range queries {
|
|
count, err := s.wrappedRowCount(errors.WrapUserQueryFailedError, ctx, query, name)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if count > 0 {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (s pgStorage) CountGroupMembers(ctx context.Context, groupActorRowID uuid.UUID) (int, error) {
|
|
return s.wrappedRowCount(errors.WrapGroupQueryFailedError, ctx, `SELECT COUNT(*) FROM group_members WHERE group_actor_id = $1`, groupActorRowID)
|
|
}
|
|
|
|
func (s pgStorage) CountGroupsMembers(ctx context.Context, groupActorRowIDs []uuid.UUID) ([]Count, error) {
|
|
if len(groupActorRowIDs) == 0 {
|
|
return []Count{}, nil
|
|
}
|
|
placeholders := common.DollarForEach(len(groupActorRowIDs))
|
|
query := fmt.Sprintf(`SELECT group_actor_id, COUNT(*) FROM group_members WHERE group_actor_id IN (%s) GROUP BY group_actor_id`, strings.Join(placeholders, ","))
|
|
return s.keyedCount(errors.WrapObjectBoostQueryFailedError, ctx, query, common.UUIDsToInterfaces(groupActorRowIDs)...)
|
|
}
|
|
|
|
func (s pgStorage) GroupMemberActorIDs(ctx context.Context, groupActorRowID uuid.UUID) ([]string, error) {
|
|
query := `SELECT a.actor_id FROM group_members gm INNER JOIN actors a ON a.id = gm.member_actor_id WHERE gm.group_actor_id = $1 ORDER BY a.actor_id ASC`
|
|
rows, err := s.db.QueryContext(ctx, query, groupActorRowID)
|
|
if err != nil {
|
|
return nil, errors.NewGroupMemberSelectFailedError(err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var actors []string
|
|
|
|
for rows.Next() {
|
|
var actor string
|
|
if err := rows.Scan(&actor); err != nil {
|
|
return nil, errors.NewGroupMemberSelectFailedError(errors.NewInvalidGroupMemberError(err))
|
|
}
|
|
actors = append(actors, actor)
|
|
}
|
|
|
|
return actors, nil
|
|
}
|
|
|
|
func (s pgStorage) GroupActorsForMemberActorRowID(ctx context.Context, memberActorRowID uuid.UUID) ([]*Actor, error) {
|
|
query := actorsSelectQuery("group_members gm ON a.id = gm.group_actor_id", []string{"gm.member_actor_id = $1"})
|
|
return s.getActors(ctx, query, memberActorRowID)
|
|
}
|
|
|
|
func (s pgStorage) GroupMemberActorsForGroupActorID(ctx context.Context, groupActorRowID uuid.UUID) ([]*Actor, error) {
|
|
query := actorsSelectQuery("group_members gm ON a.id = gm.member_actor_id", []string{"gm.group_actor_id = $1"})
|
|
return s.getActors(ctx, query, groupActorRowID)
|
|
}
|
|
|
|
func (s pgStorage) UpdateGroupMemberStatus(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, status RelationshipStatus) error {
|
|
query := "UPDATE group_members SET relationship_status = $4, updated_at = $3 WHERE group_actor_id = $1 AND member_actor_id = $2"
|
|
now := s.now()
|
|
|
|
_, err := s.db.ExecContext(ctx, query, groupActorRowID, memberActorRowID, now, status)
|
|
return errors.WrapUserUpdateFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) UpdateGroupMemberRole(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID, role GroupRole) error {
|
|
now := s.now()
|
|
_, err := s.db.ExecContext(ctx, "UPDATE group_members SET member_role = $4, updated_at = $3 WHERE group_actor_id = $1 AND member_actor_id = $2", groupActorRowID, memberActorRowID, now, role)
|
|
return errors.WrapUserUpdateFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) RemoveGroupMember(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) error {
|
|
query := `DELETE FROM group_members WHERE group_actor_id = $1 AND member_actor_id = $2`
|
|
_, err := s.db.ExecContext(ctx, query, groupActorRowID, memberActorRowID)
|
|
return errors.WrapGroupMemberUpdateFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) GroupMemberCanSubmit(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (bool, error) {
|
|
query := `SELECT COUNT(*) FROM group_members WHERE group_actor_id = $1 AND member_actor_id = $2 AND member_role IN ($3, $4)`
|
|
return s.wrappedExists(errors.WrapGroupMemberQueryFailedError, ctx, query, groupActorRowID, memberActorRowID, GroupMember, GroupOwner)
|
|
}
|
|
|
|
func (s pgStorage) GroupMemberCanInvite(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (bool, error) {
|
|
query := `SELECT COUNT(*) FROM group_members WHERE group_actor_id = $1 AND member_actor_id = $2 AND member_role IN ($3)`
|
|
return s.wrappedExists(errors.WrapGroupMemberQueryFailedError, ctx, query, groupActorRowID, memberActorRowID, GroupOwner)
|
|
}
|
|
|
|
func (s pgStorage) RecordGroupInvitation(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (uuid.UUID, error) {
|
|
rowID := NewV4()
|
|
now := s.now()
|
|
return s.RecordGroupInvitationAll(ctx, rowID, now, now, groupActorRowID, memberActorRowID)
|
|
}
|
|
|
|
func (s pgStorage) RecordGroupInvitationAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, groupActorRowID, memberActorRowID uuid.UUID) (uuid.UUID, error) {
|
|
query := `INSERT INTO group_invitations (id, created_at, updated_at, group_actor_id, member_actor_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT ON CONSTRAINT group_invitations_uindex DO UPDATE set updated_at = $3 RETURNING id`
|
|
var id uuid.UUID
|
|
err := s.db.QueryRowContext(ctx, query, rowID, createdAt, updatedAt, groupActorRowID, memberActorRowID).Scan(&id)
|
|
return id, errors.WrapGroupInvitationUpsertFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) IsActorInvitedToGroup(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (bool, error) {
|
|
return s.wrappedExists(errors.WrapGroupInvitationQueryFailedError, ctx, `SELECT COUNT(*) FROM group_invitations WHERE group_actor_id = $1 AND member_actor_id = $2`, groupActorRowID, memberActorRowID)
|
|
}
|
|
|
|
func (s pgStorage) RemoveGroupInvitation(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM group_invitations WHERE group_actor_id = $1 AND member_actor_id = $2`, groupActorRowID, memberActorRowID)
|
|
return errors.WrapGroupInvitationDeleteFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) IsActorInGroup(ctx context.Context, groupActorRowID, memberActorRowID uuid.UUID) (bool, error) {
|
|
return s.wrappedExists(errors.WrapGroupMemberQueryFailedError, ctx, `SELECT COUNT(*) FROM group_members WHERE group_actor_id = $1 AND member_actor_id = $2`, groupActorRowID, memberActorRowID)
|
|
}
|
|
|
|
func (s pgStorage) RecordGroupBoost(ctx context.Context, groupActorRowID, objectRowID uuid.UUID) (uuid.UUID, error) {
|
|
rowID := NewV4()
|
|
now := s.now()
|
|
return s.RecordGroupInvitationAll(ctx, rowID, now, now, groupActorRowID, objectRowID)
|
|
}
|
|
|
|
func (s pgStorage) RecordGroupBoostAll(ctx context.Context, rowID uuid.UUID, createdAt, updatedAt time.Time, groupActorRowID, objectRowID uuid.UUID) (uuid.UUID, error) {
|
|
query := `INSERT INTO group_boosts (id, created_at, updated_at, group_actor_id, object_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT ON CONSTRAINT group_boosts_uindex DO UPDATE set updated_at = $3 RETURNING id`
|
|
var id uuid.UUID
|
|
err := s.db.QueryRowContext(ctx, query, rowID, createdAt, updatedAt, groupActorRowID, objectRowID).Scan(&id)
|
|
return id, errors.WrapGroupInvitationUpsertFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) MinutesSinceGroupBoost(ctx context.Context, groupActorRowID, objectRowID uuid.UUID) (int, error) {
|
|
query := `SELECT (DATE_PART('day', NOW() - updated_at::timestamp) * 24 + DATE_PART('hour', NOW() - updated_at::timestamp)) * 60 + DATE_PART('minute', NOW() - updated_at::timestamp) FROM group_boosts WHERE group_actor_id = $1 AND object_id = $2`
|
|
return s.wrappedSelectInt(errors.WrapGroupBoostQueryFailedError, ctx, query, groupActorRowID, objectRowID)
|
|
}
|
|
|
|
func (s pgStorage) UpdateGroupDefaultRole(ctx context.Context, groupRowID uuid.UUID, role GroupRole) error {
|
|
now := s.now()
|
|
_, err := s.db.ExecContext(ctx, "UPDATE groups SET default_member_role = $3, updated_at = $2 WHERE id = $1", groupRowID, now, role)
|
|
return errors.WrapGroupUpdateFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) UpdateGroupAcceptFollowers(ctx context.Context, groupRowID uuid.UUID, acceptFollowers bool) error {
|
|
now := s.now()
|
|
_, err := s.db.ExecContext(ctx, "UPDATE groups SET accept_followers = $3, updated_at = $2 WHERE id = $1", groupRowID, now, acceptFollowers)
|
|
return errors.WrapGroupUpdateFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) UpdateGroupAllowRemote(ctx context.Context, groupRowID uuid.UUID, allowRemote bool) error {
|
|
now := s.now()
|
|
_, err := s.db.ExecContext(ctx, "UPDATE groups SET allow_remote = $3, updated_at = $2 WHERE id = $1", groupRowID, now, allowRemote)
|
|
return errors.WrapGroupUpdateFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) UpdateGroupAbout(ctx context.Context, groupRowID uuid.UUID, about string) error {
|
|
now := s.now()
|
|
_, err := s.db.ExecContext(ctx, "UPDATE groups SET about = $3, updated_at = $2 WHERE id = $1", groupRowID, now, about)
|
|
return errors.WrapGroupUpdateFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) UpdateGroupDirectoryOptIn(ctx context.Context, groupRowID uuid.UUID, value bool) error {
|
|
now := s.now()
|
|
_, err := s.db.ExecContext(ctx, "UPDATE groups SET directory_opt_in = $3, updated_at = $2 WHERE id = $1", groupRowID, now, value)
|
|
return errors.WrapGroupUpdateFailedError(err)
|
|
}
|
|
|
|
func (s pgStorage) ListGroupsPaginated(ctx context.Context, limit, offset int) ([]*Actor, error) {
|
|
query := actorsSelectQuery("groups g ON a.id = g.actor_id", nil) + " ORDER BY g.name ASC LIMIT $1 OFFSET $2"
|
|
return s.getActors(ctx, query, limit, offset)
|
|
}
|