1416 lines
56 KiB
Go
1416 lines
56 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
// BotVerificationStore is the PostgreSQL implementation of third-party bot
|
|
// verification (migration 0155): the icon catalogue, verifier status, the granted
|
|
// marks and the application queue in front of them.
|
|
//
|
|
// Four properties are load bearing and every method below exists to keep them:
|
|
//
|
|
// - The projection is deterministic and cheap. PeerVerification and
|
|
// PeerVerificationBatch run on every peer serialisation, so the batch form is
|
|
// a single query for all peers (no N+1). The peer unique constraint matches
|
|
// the wire model's single BotVerification value.
|
|
// - A disabled verifier projects nothing. Both projection reads join
|
|
// bot_verifier_settings and keep only enabled verifiers, which is the
|
|
// operator kill switch: flipping enabled off darkens every badge that
|
|
// verifier granted while the rows stay on disk, so flipping it back restores
|
|
// them unchanged.
|
|
// - "approved implies the mark exists". DecideCustomVerificationRequest changes
|
|
// the application status and grants (or revokes) the mark in ONE transaction:
|
|
// the caller-supplied apply callback runs inside it and must write through
|
|
// VerificationTxFromContext, so a callback error rolls the decision back and
|
|
// an approved application without its mark cannot exist.
|
|
// - Exactly one decision per application and one settings writer at a time.
|
|
// Every mutation is guarded by WHERE ... AND version = $n on top of a
|
|
// SELECT ... FOR UPDATE, so the loser of a race gets
|
|
// domain.ErrCustomVerificationVersionConflict instead of silently clobbering.
|
|
//
|
|
// Status transitions are never re-implemented in SQL: they all go through
|
|
// domain.CanTransitionCustomVerificationStatus. Re-issuing a decision that
|
|
// already holds is reported as changed=false without touching the row.
|
|
//
|
|
// Two deliberate non-responsibilities: the store does not check
|
|
// icon_document_id against the catalogue (0155 has no such foreign key, and the
|
|
// admin edge picks the icon from the catalogue before it gets here), and it does
|
|
// not refuse a grant by a disabled verifier. "May this bot verify?"
|
|
// (domain.ErrVerifierForbidden / BOT_VERIFIER_FORBIDDEN) is an RPC-edge decision
|
|
// made from the settings this store returns; the store only enforces what the
|
|
// schema encodes.
|
|
type BotVerificationStore struct {
|
|
db sqlcgen.DBTX
|
|
}
|
|
|
|
// NewBotVerificationStore builds the store on a pgx pool or transaction.
|
|
func NewBotVerificationStore(db sqlcgen.DBTX) *BotVerificationStore {
|
|
return &BotVerificationStore{db: db}
|
|
}
|
|
|
|
var _ store.BotVerificationStore = (*BotVerificationStore)(nil)
|
|
|
|
const (
|
|
defaultBotVerificationListLimit = 50
|
|
maxBotVerificationListLimit = 200
|
|
// The bounds below mirror the octet_length CHECKs of 0155. They are not
|
|
// redundant with the domain Validate methods: the domain counts runes and the
|
|
// columns count bytes, so multi-byte text can clear validation and still
|
|
// violate a CHECK. Guarding here keeps that a domain error instead of an
|
|
// opaque constraint violation from the driver, and keeps the two backends
|
|
// answering identically.
|
|
maxVerificationIconNameBytes = 512
|
|
maxVerifierCompanyBytes = 512
|
|
// Rune-counting domain limits use their worst-case UTF-8 byte size in SQL.
|
|
// The final generated description may be longer than the custom-input limit.
|
|
maxVerifierDescriptionBytes = 4 * domain.MaxCustomVerificationDescriptionLength
|
|
maxVerifierGrantReasonBytes = 4096
|
|
maxCustomVerificationDescriptionBytes = 4096
|
|
maxCustomVerificationInputBytes = 4 * domain.MaxCustomVerificationDescriptionLength
|
|
maxCustomVerificationTitleBytes = 1024
|
|
maxCustomVerificationUsernameBytes = 64
|
|
maxCustomVerificationReasonBytes = 16384
|
|
maxCustomVerificationDecidedByBytes = 128
|
|
maxCustomVerificationDecisionBytes = 4096
|
|
maxCustomVerificationNoteBytes = 32768
|
|
maxCustomVerificationCorrelationBytes = 128
|
|
)
|
|
|
|
// Constraint names the store maps onto domain errors. They are the schema's
|
|
// invariants, so a race that slips past a pre-check still reports the error the
|
|
// pre-check would have.
|
|
const (
|
|
verificationIconDocumentConstraint = "verification_icons_document_id_key"
|
|
customVerificationOnceConstraint = "custom_verifications_peer_once"
|
|
customVerificationRequestPendingIndex = "custom_verification_requests_pending_idx"
|
|
)
|
|
|
|
// Column projections shared by every reader of a table, in scan order.
|
|
const (
|
|
verificationIconColumnList = `id, document_id, owner_bot_id, name, active,
|
|
created_at, updated_at`
|
|
|
|
botVerifierSettingsColumnList = `bot_id, icon_document_id, company_name,
|
|
default_description, can_modify_custom_description, enabled, granted_by,
|
|
grant_reason, created_at, updated_at, version`
|
|
|
|
customVerificationColumnList = `id, verifier_bot_id, peer_type, peer_id,
|
|
icon_document_id, description, granted_by_user_id, created_at, updated_at,
|
|
version`
|
|
|
|
customVerificationRequestColumnList = `id, verifier_bot_id, applicant_user_id,
|
|
peer_type, peer_id, peer_title, peer_username, reason,
|
|
requested_description, status, decided_by, decision_reason, internal_note,
|
|
correlation_id, created_at, updated_at, approved_at, rejected_at, version`
|
|
)
|
|
|
|
// customVerificationJoinColumns is the mark projection qualified for the
|
|
// bot_verifier_settings join the kill switch needs, kept in sync with the plain
|
|
// list by construction rather than by hand.
|
|
var customVerificationJoinColumns = prefixBotVerificationColumns(customVerificationColumnList, "cv.")
|
|
|
|
func prefixBotVerificationColumns(list, alias string) string {
|
|
parts := strings.Split(list, ",")
|
|
for i, part := range parts {
|
|
parts[i] = alias + strings.TrimSpace(part)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// botVerificationNow is the single clock for the store. Timestamps are truncated
|
|
// to the timestamptz resolution so a value written here reads back identically.
|
|
func botVerificationNow() time.Time {
|
|
return time.Now().UTC().Truncate(time.Microsecond)
|
|
}
|
|
|
|
// ---- icon catalogue ---------------------------------------------------------
|
|
|
|
// UpsertVerificationIcon adds or updates a catalogue entry.
|
|
//
|
|
// document_id is the identity of an entry, not icon.ID: the catalogue exists to
|
|
// name real custom emoji documents, and the operator addresses them by document.
|
|
// Active travels with the payload, so an editor that means to keep an entry
|
|
// retired has to say so; SetVerificationIconActive is the narrow path for
|
|
// flipping only that flag.
|
|
func (s *BotVerificationStore) UpsertVerificationIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationIcon{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
icon.Name = strings.TrimSpace(icon.Name)
|
|
if err := icon.Validate(); err != nil {
|
|
return domain.VerificationIcon{}, err
|
|
}
|
|
if len(icon.Name) > maxVerificationIconNameBytes {
|
|
return domain.VerificationIcon{}, domain.ErrVerificationIconInvalid
|
|
}
|
|
now := botVerificationNow()
|
|
stored, err := scanVerificationIcon(s.db.QueryRow(ctx, `
|
|
INSERT INTO verification_icons (
|
|
document_id, owner_bot_id, name, active, created_at, updated_at
|
|
) VALUES ($1,$2,$3,$4,$5,$5)
|
|
ON CONFLICT ON CONSTRAINT `+verificationIconDocumentConstraint+` DO UPDATE
|
|
SET owner_bot_id = EXCLUDED.owner_bot_id,
|
|
name = EXCLUDED.name,
|
|
active = EXCLUDED.active,
|
|
updated_at = GREATEST(verification_icons.updated_at, EXCLUDED.updated_at)
|
|
RETURNING `+verificationIconColumnList,
|
|
icon.DocumentID, icon.OwnerBotID, icon.Name, icon.Active, now,
|
|
))
|
|
if err != nil {
|
|
return domain.VerificationIcon{}, fmt.Errorf("upsert verification icon: %w", err)
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
// SetVerificationIconActive retires or restores an entry. Marks already granted
|
|
// with it keep rendering: the icon id is denormalised onto the mark.
|
|
func (s *BotVerificationStore) SetVerificationIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationIcon{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if iconID <= 0 {
|
|
return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound
|
|
}
|
|
icon, err := scanVerificationIcon(s.db.QueryRow(ctx, `
|
|
UPDATE verification_icons
|
|
SET active = $2, updated_at = GREATEST(updated_at, $3)
|
|
WHERE id = $1
|
|
RETURNING `+verificationIconColumnList, iconID, active, botVerificationNow()))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationIcon{}, fmt.Errorf("set verification icon active: %w", err)
|
|
}
|
|
return icon, nil
|
|
}
|
|
|
|
// VerificationIcon reads one entry by id.
|
|
func (s *BotVerificationStore) VerificationIcon(ctx context.Context, iconID int64) (domain.VerificationIcon, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationIcon{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if iconID <= 0 {
|
|
return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound
|
|
}
|
|
icon, err := scanVerificationIcon(s.db.QueryRow(ctx, `
|
|
SELECT `+verificationIconColumnList+`
|
|
FROM verification_icons
|
|
WHERE id = $1`, iconID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationIcon{}, fmt.Errorf("get verification icon: %w", err)
|
|
}
|
|
return icon, nil
|
|
}
|
|
|
|
// VerificationIconByDocument reads one entry by its custom emoji document id,
|
|
// which is how the admin edge resolves an icon a verifier already carries.
|
|
func (s *BotVerificationStore) VerificationIconByDocument(ctx context.Context, documentID int64) (domain.VerificationIcon, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationIcon{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if documentID <= 0 {
|
|
return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound
|
|
}
|
|
icon, err := scanVerificationIcon(s.db.QueryRow(ctx, `
|
|
SELECT `+verificationIconColumnList+`
|
|
FROM verification_icons
|
|
WHERE document_id = $1`, documentID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationIcon{}, domain.ErrVerificationIconNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationIcon{}, fmt.Errorf("get verification icon by document: %w", err)
|
|
}
|
|
return icon, nil
|
|
}
|
|
|
|
// ListVerificationIcons lists the catalogue, newest first. The order is the tail
|
|
// of verification_icons_active_idx, so the activeOnly form is an index-only walk.
|
|
func (s *BotVerificationStore) ListVerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
limit = botVerificationLimit(limit)
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+verificationIconColumnList+`
|
|
FROM verification_icons
|
|
WHERE NOT $1::boolean OR active
|
|
ORDER BY id DESC
|
|
LIMIT $2`, activeOnly, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list verification icons: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.VerificationIcon, 0, limit)
|
|
for rows.Next() {
|
|
icon, err := scanVerificationIcon(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan verification icon: %w", err)
|
|
}
|
|
out = append(out, icon)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate verification icons: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- verifier status --------------------------------------------------------
|
|
|
|
// UpsertBotVerifierSettings grants or updates verifier status.
|
|
//
|
|
// settings.Version is the optimistic-locking expectation: 0 means "there is no
|
|
// verifier row yet" and a stored row then reports
|
|
// domain.ErrCustomVerificationVersionConflict, and a non-zero version must match
|
|
// the stored one. created_at is never rewritten, so the grant date survives every
|
|
// later edit.
|
|
func (s *BotVerificationStore) UpsertBotVerifierSettings(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.BotVerifierSettings{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
settings.CompanyName = strings.TrimSpace(settings.CompanyName)
|
|
settings.DefaultDescription = strings.TrimSpace(settings.DefaultDescription)
|
|
settings.GrantedBy = strings.TrimSpace(settings.GrantedBy)
|
|
settings.GrantReason = strings.TrimSpace(settings.GrantReason)
|
|
if err := settings.Validate(); err != nil {
|
|
return domain.BotVerifierSettings{}, err
|
|
}
|
|
if settings.Version < 0 || !botVerifierSettingsColumnsFit(settings) {
|
|
return domain.BotVerifierSettings{}, domain.ErrVerifierSettingsInvalid
|
|
}
|
|
var stored domain.BotVerifierSettings
|
|
err := withTx(ctx, s.db, "upsert bot verifier settings", func(tx pgx.Tx) error {
|
|
current, err := lockBotVerifierSettingsTx(ctx, tx, settings.BotID)
|
|
switch {
|
|
case errors.Is(err, domain.ErrVerifierNotFound):
|
|
if settings.Version != 0 {
|
|
// The caller edits a row that is no longer there.
|
|
return domain.ErrVerifierNotFound
|
|
}
|
|
now := botVerificationNow()
|
|
inserted, err := scanBotVerifierSettings(tx.QueryRow(ctx, `
|
|
INSERT INTO bot_verifier_settings (
|
|
bot_id, icon_document_id, company_name, default_description,
|
|
can_modify_custom_description, enabled, granted_by, grant_reason,
|
|
created_at, updated_at, version
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9,1)
|
|
RETURNING `+botVerifierSettingsColumnList,
|
|
settings.BotID, settings.IconDocumentID, settings.CompanyName,
|
|
settings.DefaultDescription, settings.CanModifyCustomDescription,
|
|
settings.Enabled, settings.GrantedBy, settings.GrantReason, now,
|
|
))
|
|
if err != nil {
|
|
return fmt.Errorf("insert bot verifier settings: %w", err)
|
|
}
|
|
stored = inserted
|
|
return nil
|
|
case err != nil:
|
|
return err
|
|
}
|
|
if settings.Version == 0 || settings.Version != current.Version {
|
|
return domain.ErrCustomVerificationVersionConflict
|
|
}
|
|
updated, err := scanBotVerifierSettings(tx.QueryRow(ctx, `
|
|
UPDATE bot_verifier_settings
|
|
SET icon_document_id = $3,
|
|
company_name = $4,
|
|
default_description = $5,
|
|
can_modify_custom_description = $6,
|
|
enabled = $7,
|
|
granted_by = $8,
|
|
grant_reason = $9,
|
|
version = version + 1,
|
|
updated_at = GREATEST(updated_at, $10)
|
|
WHERE bot_id = $1 AND version = $2
|
|
RETURNING `+botVerifierSettingsColumnList,
|
|
settings.BotID, settings.Version, settings.IconDocumentID,
|
|
settings.CompanyName, settings.DefaultDescription,
|
|
settings.CanModifyCustomDescription, settings.Enabled,
|
|
settings.GrantedBy, settings.GrantReason, botVerificationNow(),
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrCustomVerificationVersionConflict
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("update bot verifier settings: %w", err)
|
|
}
|
|
stored = updated
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.BotVerifierSettings{}, err
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
// SetBotVerifierEnabled flips the operator kill switch. Existing marks stay on
|
|
// disk, but the verifier can grant nothing new and neither its settings nor its
|
|
// marks are projected, so flipping the switch back restores exactly what was
|
|
// there. Setting the flag to the value it already has is a no-op and does not
|
|
// burn a version.
|
|
func (s *BotVerificationStore) SetBotVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.BotVerifierSettings{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if botID <= 0 {
|
|
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
|
}
|
|
var stored domain.BotVerifierSettings
|
|
err := withTx(ctx, s.db, "set bot verifier enabled", func(tx pgx.Tx) error {
|
|
current, err := lockBotVerifierSettingsTx(ctx, tx, botID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Enabled == enabled {
|
|
stored = current
|
|
return nil
|
|
}
|
|
updated, err := scanBotVerifierSettings(tx.QueryRow(ctx, `
|
|
UPDATE bot_verifier_settings
|
|
SET enabled = $2, version = version + 1, updated_at = GREATEST(updated_at, $3)
|
|
WHERE bot_id = $1
|
|
RETURNING `+botVerifierSettingsColumnList, botID, enabled, botVerificationNow()))
|
|
if err != nil {
|
|
return fmt.Errorf("set bot verifier enabled: %w", err)
|
|
}
|
|
stored = updated
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.BotVerifierSettings{}, err
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
// DeleteBotVerifierSettings removes verifier status. Its marks cascade away with
|
|
// it (custom_verifications.verifier_bot_id ON DELETE CASCADE), because a mark
|
|
// whose verifier no longer exists has nothing to render. Applications survive:
|
|
// they reference users, not the verifier row, and stay as history.
|
|
func (s *BotVerificationStore) DeleteBotVerifierSettings(ctx context.Context, botID int64) (bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return false, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if botID <= 0 {
|
|
return false, domain.ErrVerifierNotFound
|
|
}
|
|
tag, err := s.db.Exec(ctx, `DELETE FROM bot_verifier_settings WHERE bot_id = $1`, botID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("delete bot verifier settings: %w", err)
|
|
}
|
|
return tag.RowsAffected() > 0, nil
|
|
}
|
|
|
|
// BotVerifierSettings reads one verifier's status, enabled or not: the caller
|
|
// needs the disabled row too, to render the kill switch and to explain
|
|
// BOT_VERIFIER_FORBIDDEN.
|
|
func (s *BotVerificationStore) BotVerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.BotVerifierSettings{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if botID <= 0 {
|
|
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
|
}
|
|
settings, err := scanBotVerifierSettings(s.db.QueryRow(ctx, `
|
|
SELECT `+botVerifierSettingsColumnList+`
|
|
FROM bot_verifier_settings
|
|
WHERE bot_id = $1`, botID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.BotVerifierSettings{}, fmt.Errorf("get bot verifier settings: %w", err)
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
// BotVerifierSettingsBatch resolves several bots in one round trip for the
|
|
// botInfo projection; bots without verifier status are absent from the map.
|
|
// Disabled verifiers are returned like any other row -- the Enabled field says
|
|
// so, and the projection edge decides -- which mirrors ListBotVerifiers taking
|
|
// enabledOnly as an explicit argument instead of assuming it.
|
|
func (s *BotVerificationStore) BotVerifierSettingsBatch(ctx context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
ids := make([]int64, 0, len(botIDs))
|
|
seen := make(map[int64]struct{}, len(botIDs))
|
|
for _, id := range botIDs {
|
|
if id <= 0 {
|
|
continue
|
|
}
|
|
if _, dup := seen[id]; dup {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
ids = append(ids, id)
|
|
}
|
|
out := make(map[int64]domain.BotVerifierSettings, len(ids))
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+botVerifierSettingsColumnList+`
|
|
FROM bot_verifier_settings
|
|
WHERE bot_id = ANY($1::bigint[])`, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("batch bot verifier settings: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
settings, err := scanBotVerifierSettings(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan bot verifier settings: %w", err)
|
|
}
|
|
out[settings.BotID] = settings
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate bot verifier settings: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListBotVerifiers lists verifier bots for the admin panel, ordered the way
|
|
// bot_verifier_settings_enabled_idx is built.
|
|
func (s *BotVerificationStore) ListBotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
limit = botVerificationLimit(limit)
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+botVerifierSettingsColumnList+`
|
|
FROM bot_verifier_settings
|
|
WHERE NOT $1::boolean OR enabled
|
|
ORDER BY bot_id
|
|
LIMIT $2`, enabledOnly, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list bot verifiers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.BotVerifierSettings, 0, limit)
|
|
for rows.Next() {
|
|
settings, err := scanBotVerifierSettings(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan bot verifier: %w", err)
|
|
}
|
|
out = append(out, settings)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate bot verifiers: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- granted marks ---------------------------------------------------------
|
|
|
|
// GrantCustomVerification creates or updates this verifier's mark on the peer.
|
|
//
|
|
// custom_verifications_peer_once makes the peer the identity of a mark. A repeat
|
|
// by the same verifier updates in place; a different verifier replaces the mark
|
|
// because the wire model can carry only one BotVerification.
|
|
//
|
|
// The verifier row is locked first: it is both the existence check
|
|
// (domain.ErrVerifierNotFound) and the serialisation point that makes the
|
|
// per-verifier bound real. Two concurrent grants by one verifier queue up, so
|
|
// the count they check cannot go stale between the check and the insert and
|
|
// domain.MaxCustomVerificationsPerVerifier cannot be overshot.
|
|
//
|
|
// mark.IconDocumentID is denormalised from the verifier's settings when the
|
|
// caller leaves it unset, which is what "the icon is taken from the verifier at
|
|
// grant time" means; an explicit id is honoured, so re-issuing a historical mark
|
|
// keeps its original icon.
|
|
func (s *BotVerificationStore) GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CustomVerification{}, false, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
mark.Description = strings.TrimSpace(mark.Description)
|
|
if mark.VerifierBotID <= 0 || !validBotVerificationPeer(mark.Peer) {
|
|
return domain.CustomVerification{}, false, domain.ErrCustomVerificationTargetInvalid
|
|
}
|
|
if mark.GrantedByUserID < 0 {
|
|
return domain.CustomVerification{}, false, domain.ErrCustomVerificationTargetInvalid
|
|
}
|
|
if len(mark.Description) > maxCustomVerificationDescriptionBytes {
|
|
return domain.CustomVerification{}, false, domain.ErrCustomVerificationRequestInvalid
|
|
}
|
|
var stored domain.CustomVerification
|
|
created := false
|
|
err := withTx(ctx, s.db, "grant custom verification", func(tx pgx.Tx) error {
|
|
settings, err := lockBotVerifierSettingsTx(ctx, tx, mark.VerifierBotID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mark.IconDocumentID <= 0 {
|
|
mark.IconDocumentID = settings.IconDocumentID
|
|
}
|
|
if err := mark.Validate(); err != nil {
|
|
return err
|
|
}
|
|
existed := true
|
|
switch _, err := customVerificationTx(ctx, tx, mark.VerifierBotID, mark.Peer, true); {
|
|
case err == nil:
|
|
case errors.Is(err, domain.ErrCustomVerificationNotFound):
|
|
existed = false
|
|
default:
|
|
return err
|
|
}
|
|
if !existed {
|
|
count, err := countCustomVerificationsTx(ctx, tx, mark.VerifierBotID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count >= domain.MaxCustomVerificationsPerVerifier {
|
|
return domain.ErrCustomVerificationLimit
|
|
}
|
|
}
|
|
now := botVerificationNow()
|
|
upserted, err := scanCustomVerification(tx.QueryRow(ctx, `
|
|
INSERT INTO custom_verifications (
|
|
verifier_bot_id, peer_type, peer_id, icon_document_id, description,
|
|
granted_by_user_id, created_at, updated_at, version
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$7,1)
|
|
ON CONFLICT ON CONSTRAINT `+customVerificationOnceConstraint+` DO UPDATE
|
|
SET verifier_bot_id = EXCLUDED.verifier_bot_id,
|
|
icon_document_id = EXCLUDED.icon_document_id,
|
|
description = EXCLUDED.description,
|
|
granted_by_user_id = EXCLUDED.granted_by_user_id,
|
|
created_at = CASE
|
|
WHEN custom_verifications.verifier_bot_id = EXCLUDED.verifier_bot_id
|
|
THEN custom_verifications.created_at
|
|
ELSE EXCLUDED.created_at
|
|
END,
|
|
version = custom_verifications.version + 1,
|
|
updated_at = GREATEST(custom_verifications.updated_at, EXCLUDED.updated_at)
|
|
RETURNING `+customVerificationColumnList,
|
|
mark.VerifierBotID, string(mark.Peer.Type), mark.Peer.ID,
|
|
mark.IconDocumentID, mark.Description, mark.GrantedByUserID, now,
|
|
))
|
|
if err != nil {
|
|
return fmt.Errorf("grant custom verification: %w", err)
|
|
}
|
|
stored = upserted
|
|
created = !existed
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.CustomVerification{}, false, err
|
|
}
|
|
return stored, created, nil
|
|
}
|
|
|
|
// RevokeCustomVerification removes this verifier's mark from the peer and
|
|
// reports whether anything was removed, so a repeated revoke is a no-op instead
|
|
// of an error. Only this verifier's mark goes: another verifier's mark on the
|
|
// same peer is none of its business.
|
|
func (s *BotVerificationStore) RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return false, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if verifierBotID <= 0 || !validBotVerificationPeer(peer) {
|
|
return false, domain.ErrCustomVerificationTargetInvalid
|
|
}
|
|
tag, err := s.db.Exec(ctx, `
|
|
DELETE FROM custom_verifications
|
|
WHERE verifier_bot_id = $1 AND peer_type = $2 AND peer_id = $3`,
|
|
verifierBotID, string(peer.Type), peer.ID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("revoke custom verification: %w", err)
|
|
}
|
|
return tag.RowsAffected() > 0, nil
|
|
}
|
|
|
|
// CustomVerification reads one verifier's mark on a peer, whether or not that
|
|
// verifier is currently enabled: this is the bookkeeping read, not the
|
|
// projection.
|
|
func (s *BotVerificationStore) CustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerification, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CustomVerification{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if verifierBotID <= 0 || !validBotVerificationPeer(peer) {
|
|
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
|
|
}
|
|
return customVerificationTx(ctx, s.db, verifierBotID, peer, false)
|
|
}
|
|
|
|
// PeerVerification returns the mark a peer is rendered with.
|
|
//
|
|
// Only an enabled verifier projects. The schema guarantees one mark per peer;
|
|
// ORDER BY remains a defensive stable read for databases created during
|
|
// development before that invariant was folded into the initial migration.
|
|
func (s *BotVerificationStore) PeerVerification(ctx context.Context, peer domain.Peer) (domain.CustomVerification, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CustomVerification{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if !validBotVerificationPeer(peer) {
|
|
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
|
|
}
|
|
mark, err := scanCustomVerification(s.db.QueryRow(ctx, `
|
|
SELECT `+customVerificationJoinColumns+`
|
|
FROM custom_verifications cv
|
|
JOIN bot_verifier_settings s ON s.bot_id = cv.verifier_bot_id AND s.enabled
|
|
WHERE cv.peer_type = $1 AND cv.peer_id = $2
|
|
ORDER BY cv.id DESC
|
|
LIMIT 1`, string(peer.Type), peer.ID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.CustomVerification{}, fmt.Errorf("get peer verification: %w", err)
|
|
}
|
|
return mark, nil
|
|
}
|
|
|
|
// PeerVerificationBatch resolves the projection for many peers at once.
|
|
//
|
|
// This is the call on the hot serialisation path, so it is ONE query for the
|
|
// whole batch: DISTINCT ON (peer_type, peer_id) with ORDER BY ... cv.id DESC
|
|
// applies the same enabled-verifier rule per peer that PeerVerification would, and
|
|
// peers without a mark are simply absent instead of erroring. Sending N queries
|
|
// here would put a per-peer round trip on every dialog list.
|
|
func (s *BotVerificationStore) PeerVerificationBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
types, ids := botVerificationPeerArrays(peers)
|
|
out := make(map[domain.Peer]domain.CustomVerification, len(ids))
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT DISTINCT ON (cv.peer_type, cv.peer_id) `+customVerificationJoinColumns+`
|
|
FROM custom_verifications cv
|
|
JOIN bot_verifier_settings s ON s.bot_id = cv.verifier_bot_id AND s.enabled
|
|
WHERE (cv.peer_type, cv.peer_id) IN (SELECT * FROM unnest($1::text[], $2::bigint[]))
|
|
ORDER BY cv.peer_type, cv.peer_id, cv.id DESC`, types, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("batch peer verification: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
mark, err := scanCustomVerification(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan peer verification: %w", err)
|
|
}
|
|
out[mark.Peer] = mark
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate peer verifications: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CountCustomVerifications reports how many peers a verifier has marked, for the
|
|
// per-verifier bound. Disabled verifiers still count their marks: the switch
|
|
// hides badges, it does not free quota.
|
|
func (s *BotVerificationStore) CountCustomVerifications(ctx context.Context, verifierBotID int64) (int, error) {
|
|
if s == nil || s.db == nil {
|
|
return 0, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if verifierBotID <= 0 {
|
|
return 0, domain.ErrCustomVerificationTargetInvalid
|
|
}
|
|
return countCustomVerificationsTx(ctx, s.db, verifierBotID)
|
|
}
|
|
|
|
// ListCustomVerifications is the admin listing query with keyset paging.
|
|
//
|
|
// Paging is keyset over id DESC (filter.BeforeID carries the last row of the
|
|
// previous page), which is the tail of custom_verifications_verifier_idx and of
|
|
// custom_verifications_peer_idx. Query matches a mark id or a peer id when it is
|
|
// numeric and otherwise matches the description case-insensitively, the only
|
|
// text a mark carries.
|
|
func (s *BotVerificationStore) ListCustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if filter.PeerType != "" && !botVerificationPeerType(filter.PeerType) {
|
|
return nil, domain.ErrCustomVerificationTargetInvalid
|
|
}
|
|
limit := botVerificationLimit(filter.Limit)
|
|
numeric, isNumeric, needle := parseBotVerificationQuery(filter.Query)
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+customVerificationColumnList+`
|
|
FROM custom_verifications
|
|
WHERE ($1 = 0 OR verifier_bot_id = $1)
|
|
AND ($2 = '' OR peer_type = $2)
|
|
AND ($3 = 0 OR peer_id = $3)
|
|
AND ($4 = 0 OR id < $4)
|
|
AND (
|
|
NOT $5::boolean
|
|
OR ($6::boolean AND (id = $7::bigint OR peer_id = $7::bigint))
|
|
OR (NOT $6::boolean AND lower(description) LIKE '%' || $8::text || '%')
|
|
)
|
|
ORDER BY id DESC
|
|
LIMIT $9`,
|
|
filter.VerifierBotID, string(filter.PeerType), filter.PeerID,
|
|
filter.BeforeID, isNumeric || needle != "", isNumeric, numeric,
|
|
escapeLike(needle), limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list custom verifications: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.CustomVerification, 0, limit)
|
|
for rows.Next() {
|
|
mark, err := scanCustomVerification(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan custom verification: %w", err)
|
|
}
|
|
out = append(out, mark)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate custom verifications: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- application queue -----------------------------------------------------
|
|
|
|
// CreateCustomVerificationRequest files an application.
|
|
//
|
|
// A filed application is pending by definition, so the status is forced and any
|
|
// decision field the caller pre-filled is dropped: only
|
|
// DecideCustomVerificationRequest may write those.
|
|
// custom_verification_requests_pending_idx allows one live application per
|
|
// (verifier, peer), and a second one reports
|
|
// domain.ErrCustomVerificationRequestExists -- two pending rows would let two
|
|
// decisions race for one mark.
|
|
func (s *BotVerificationStore) CreateCustomVerificationRequest(ctx context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CustomVerificationRequest{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
req = normalizeCustomVerificationRequest(req)
|
|
if req.Status == "" {
|
|
req.Status = domain.CustomVerificationPending
|
|
}
|
|
if req.Status != domain.CustomVerificationPending {
|
|
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestInvalid
|
|
}
|
|
req.DecidedBy = ""
|
|
req.DecisionReason = ""
|
|
if err := req.Validate(); err != nil {
|
|
return domain.CustomVerificationRequest{}, err
|
|
}
|
|
if err := validateCustomVerificationRequestColumns(req); err != nil {
|
|
return domain.CustomVerificationRequest{}, err
|
|
}
|
|
now := botVerificationNow()
|
|
stored, err := scanCustomVerificationRequest(s.db.QueryRow(ctx, `
|
|
INSERT INTO custom_verification_requests (
|
|
verifier_bot_id, applicant_user_id, peer_type, peer_id, peer_title,
|
|
peer_username, reason, requested_description, status, internal_note,
|
|
correlation_id, created_at, updated_at, version
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9,$10,$11,$11,1)
|
|
RETURNING `+customVerificationRequestColumnList,
|
|
req.VerifierBotID, req.ApplicantUserID, string(req.Peer.Type), req.Peer.ID,
|
|
req.PeerTitle, req.PeerUsername, req.Reason, req.RequestedDescription,
|
|
req.InternalNote, req.CorrelationID, now,
|
|
))
|
|
if err != nil {
|
|
if isUniqueConstraint(err, customVerificationRequestPendingIndex) {
|
|
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestExists
|
|
}
|
|
return domain.CustomVerificationRequest{}, fmt.Errorf("insert custom verification request: %w", err)
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
// DecideCustomVerificationRequest moves an application through its status
|
|
// machine and keeps the mark in step with it.
|
|
//
|
|
// The whole decision is one transaction: the status change, the decision
|
|
// metadata, the approved_at/rejected_at stamps and the apply callback that
|
|
// grants (status approved) or removes (status revoked) the mark. apply is handed
|
|
// a context carrying this transaction, so it must write through
|
|
// VerificationTxFromContext -- for example
|
|
// postgres.NewBotVerificationStore(tx).GrantCustomVerification. A callback that
|
|
// fails rolls the status change back with it, which is why "approved without a
|
|
// mark" is not a reachable state; a callback that ignores the handle would write
|
|
// on its own connection and survive that rollback.
|
|
//
|
|
// Order of checks is the deterministic part of two reviewers acting at once: the
|
|
// version is compared first, so the loser always sees
|
|
// domain.ErrCustomVerificationVersionConflict. A caller re-issuing a decision
|
|
// that already holds gets the request back with changed=false, no second stamp
|
|
// and no second apply -- the callback is not idempotent by assumption.
|
|
//
|
|
// The transition itself is domain.CanTransitionCustomVerificationStatus, never
|
|
// re-implemented here, and the resulting row is validated with
|
|
// domain.CustomVerificationRequest.Validate, which is what rejects a rejection
|
|
// with no reason (domain.ErrVerificationReasonRequired).
|
|
func (s *BotVerificationStore) DecideCustomVerificationRequest(ctx context.Context, requestID int64, version int64, status domain.CustomVerificationRequestStatus, decidedBy, reason, note string, apply func(ctx context.Context, req domain.CustomVerificationRequest) error) (domain.CustomVerificationRequest, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CustomVerificationRequest{}, false, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
decidedBy = strings.TrimSpace(decidedBy)
|
|
reason = strings.TrimSpace(reason)
|
|
note = strings.TrimSpace(note)
|
|
if requestID <= 0 || version <= 0 {
|
|
return domain.CustomVerificationRequest{}, false, domain.ErrCustomVerificationRequestInvalid
|
|
}
|
|
if !status.Valid() || status == domain.CustomVerificationPending {
|
|
// Pending is where an application starts, not a decision anybody makes.
|
|
return domain.CustomVerificationRequest{}, false, domain.ErrCustomVerificationRequestInvalid
|
|
}
|
|
if customVerificationDecisionNeedsApply(status) && apply == nil {
|
|
// Deciding without a way to move the mark is exactly the state this store
|
|
// exists to make impossible.
|
|
return domain.CustomVerificationRequest{}, false, fmt.Errorf("custom verification decision %q requires an apply callback", status)
|
|
}
|
|
var stored domain.CustomVerificationRequest
|
|
changed := false
|
|
err := withTx(ctx, s.db, "decide custom verification request", func(tx pgx.Tx) error {
|
|
current, err := lockCustomVerificationRequestTx(ctx, tx, requestID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Version != version {
|
|
return domain.ErrCustomVerificationVersionConflict
|
|
}
|
|
if current.Status == status {
|
|
// Already decided this way: keep the record and report that nothing
|
|
// moved, so a retried decision cannot apply the mark twice.
|
|
stored = current
|
|
return nil
|
|
}
|
|
if !domain.CanTransitionCustomVerificationStatus(current.Status, status) {
|
|
return domain.ErrCustomVerificationRequestInvalid
|
|
}
|
|
now := botVerificationNow()
|
|
next := customVerificationDecisionState(current, status, decidedBy, reason, note, now)
|
|
if err := next.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCustomVerificationRequestColumns(next); err != nil {
|
|
return err
|
|
}
|
|
updated, err := scanCustomVerificationRequest(tx.QueryRow(ctx, `
|
|
UPDATE custom_verification_requests
|
|
SET status = $3,
|
|
decided_by = $4,
|
|
decision_reason = $5,
|
|
internal_note = $6,
|
|
approved_at = $7,
|
|
rejected_at = $8,
|
|
version = version + 1,
|
|
updated_at = GREATEST(updated_at, $9)
|
|
WHERE id = $1 AND version = $2
|
|
RETURNING `+customVerificationRequestColumnList,
|
|
requestID, version, string(next.Status), next.DecidedBy,
|
|
next.DecisionReason, next.InternalNote,
|
|
botVerificationTimeArg(next.ApprovedAt),
|
|
botVerificationTimeArg(next.RejectedAt), now,
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrCustomVerificationVersionConflict
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("decide custom verification request: %w", err)
|
|
}
|
|
if customVerificationDecisionNeedsApply(status) {
|
|
if err := apply(verificationTxContext(ctx, tx), updated); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
stored = updated
|
|
changed = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.CustomVerificationRequest{}, false, err
|
|
}
|
|
return stored, changed, nil
|
|
}
|
|
|
|
// CustomVerificationRequest reads one application.
|
|
func (s *BotVerificationStore) CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CustomVerificationRequest{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if requestID <= 0 {
|
|
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
|
}
|
|
req, err := scanCustomVerificationRequest(s.db.QueryRow(ctx, `
|
|
SELECT `+customVerificationRequestColumnList+`
|
|
FROM custom_verification_requests
|
|
WHERE id = $1`, requestID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.CustomVerificationRequest{}, fmt.Errorf("get custom verification request: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// PendingCustomVerificationRequest returns the live application for a
|
|
// (verifier, peer) pair. The partial unique index guarantees there is at most
|
|
// one, so no ordering is needed to pick it.
|
|
func (s *BotVerificationStore) PendingCustomVerificationRequest(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.CustomVerificationRequest{}, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if verifierBotID <= 0 || !validBotVerificationPeer(peer) {
|
|
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
|
}
|
|
req, err := scanCustomVerificationRequest(s.db.QueryRow(ctx, `
|
|
SELECT `+customVerificationRequestColumnList+`
|
|
FROM custom_verification_requests
|
|
WHERE verifier_bot_id = $1 AND peer_type = $2 AND peer_id = $3
|
|
AND status = 'pending'
|
|
LIMIT 1`, verifierBotID, string(peer.Type), peer.ID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.CustomVerificationRequest{}, fmt.Errorf("get pending custom verification request: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// ListCustomVerificationRequests is the review-queue query with keyset paging.
|
|
//
|
|
// Paging is keyset over id DESC: the filter carries no timestamp cursor, and ids
|
|
// are monotonic, so id DESC is the same page order as created_at DESC without
|
|
// the ambiguity a mixed cursor would have. Query matches an application id or a
|
|
// peer id when it is numeric and otherwise prefix-matches the lowercased
|
|
// username snapshot, the same split the official verification queue uses.
|
|
func (s *BotVerificationStore) ListCustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
statuses := make([]string, 0, len(filter.Statuses))
|
|
for _, status := range filter.Statuses {
|
|
if !status.Valid() {
|
|
return nil, domain.ErrCustomVerificationRequestInvalid
|
|
}
|
|
statuses = append(statuses, string(status))
|
|
}
|
|
if filter.PeerType != "" && !botVerificationPeerType(filter.PeerType) {
|
|
return nil, domain.ErrCustomVerificationTargetInvalid
|
|
}
|
|
limit := botVerificationLimit(filter.Limit)
|
|
numeric, isNumeric, prefix := parseBotVerificationQuery(filter.Query)
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+customVerificationRequestColumnList+`
|
|
FROM custom_verification_requests
|
|
WHERE (cardinality($1::text[]) = 0 OR status = ANY($1::text[]))
|
|
AND ($2 = 0 OR verifier_bot_id = $2)
|
|
AND ($3 = '' OR peer_type = $3)
|
|
AND ($4 = 0 OR id < $4)
|
|
AND (
|
|
NOT $5::boolean
|
|
OR ($6::boolean AND (id = $7::bigint OR peer_id = $7::bigint))
|
|
OR (
|
|
NOT $6::boolean
|
|
AND peer_username <> ''
|
|
AND lower(peer_username) LIKE $8::text || '%'
|
|
)
|
|
)
|
|
ORDER BY id DESC
|
|
LIMIT $9`,
|
|
statuses, filter.VerifierBotID, string(filter.PeerType), filter.BeforeID,
|
|
isNumeric || prefix != "", isNumeric, numeric, escapeLike(prefix), limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list custom verification requests: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.CustomVerificationRequest, 0, limit)
|
|
for rows.Next() {
|
|
req, err := scanCustomVerificationRequest(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan custom verification request: %w", err)
|
|
}
|
|
out = append(out, req)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate custom verification requests: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CustomVerificationRequestsForApplicant returns an applicant's own history,
|
|
// newest first, for the verifier bot's /status command.
|
|
func (s *BotVerificationStore) CustomVerificationRequestsForApplicant(ctx context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
if applicantUserID <= 0 {
|
|
return nil, domain.ErrCustomVerificationRequestInvalid
|
|
}
|
|
limit = botVerificationLimit(limit)
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+customVerificationRequestColumnList+`
|
|
FROM custom_verification_requests
|
|
WHERE applicant_user_id = $1
|
|
ORDER BY id DESC
|
|
LIMIT $2`, applicantUserID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list applicant custom verification requests: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.CustomVerificationRequest, 0, limit)
|
|
for rows.Next() {
|
|
req, err := scanCustomVerificationRequest(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan applicant custom verification request: %w", err)
|
|
}
|
|
out = append(out, req)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate applicant custom verification requests: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CustomVerificationRequestCounts is the queue summary by status. Statuses
|
|
// nobody is in are absent rather than zero, which a map read cannot tell apart
|
|
// anyway.
|
|
func (s *BotVerificationStore) CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("bot verification store is not configured")
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT status, count(*)
|
|
FROM custom_verification_requests
|
|
GROUP BY status`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count custom verification requests: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make(map[domain.CustomVerificationRequestStatus]int64, 4)
|
|
for rows.Next() {
|
|
var status string
|
|
var count int64
|
|
if err := rows.Scan(&status, &count); err != nil {
|
|
return nil, fmt.Errorf("scan custom verification request count: %w", err)
|
|
}
|
|
out[domain.CustomVerificationRequestStatus(status)] = count
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate custom verification request counts: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- helpers ----------------------------------------------------------------
|
|
|
|
func scanVerificationIcon(row pgx.Row) (domain.VerificationIcon, error) {
|
|
var icon domain.VerificationIcon
|
|
if err := row.Scan(&icon.ID, &icon.DocumentID, &icon.OwnerBotID, &icon.Name,
|
|
&icon.Active, &icon.CreatedAt, &icon.UpdatedAt); err != nil {
|
|
return domain.VerificationIcon{}, err
|
|
}
|
|
icon.CreatedAt = icon.CreatedAt.UTC()
|
|
icon.UpdatedAt = icon.UpdatedAt.UTC()
|
|
return icon, nil
|
|
}
|
|
|
|
func scanBotVerifierSettings(row pgx.Row) (domain.BotVerifierSettings, error) {
|
|
var settings domain.BotVerifierSettings
|
|
if err := row.Scan(&settings.BotID, &settings.IconDocumentID,
|
|
&settings.CompanyName, &settings.DefaultDescription,
|
|
&settings.CanModifyCustomDescription, &settings.Enabled,
|
|
&settings.GrantedBy, &settings.GrantReason, &settings.CreatedAt,
|
|
&settings.UpdatedAt, &settings.Version); err != nil {
|
|
return domain.BotVerifierSettings{}, err
|
|
}
|
|
settings.CreatedAt = settings.CreatedAt.UTC()
|
|
settings.UpdatedAt = settings.UpdatedAt.UTC()
|
|
return settings, nil
|
|
}
|
|
|
|
// lockBotVerifierSettingsTx reads the verifier row for mutation. It is both the
|
|
// existence check and the serialisation point every grant by that verifier goes
|
|
// through, which is what makes the per-verifier bound hold under concurrency.
|
|
func lockBotVerifierSettingsTx(ctx context.Context, tx pgx.Tx, botID int64) (domain.BotVerifierSettings, error) {
|
|
settings, err := scanBotVerifierSettings(tx.QueryRow(ctx, `
|
|
SELECT `+botVerifierSettingsColumnList+`
|
|
FROM bot_verifier_settings
|
|
WHERE bot_id = $1
|
|
FOR UPDATE`, botID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.BotVerifierSettings{}, domain.ErrVerifierNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.BotVerifierSettings{}, fmt.Errorf("lock bot verifier settings: %w", err)
|
|
}
|
|
return settings, nil
|
|
}
|
|
|
|
// customVerificationRow adapts the mark projection onto the domain type: the
|
|
// peer arrives as (text, bigint) rather than as a domain.Peer.
|
|
type customVerificationRow struct {
|
|
mark domain.CustomVerification
|
|
peerType string
|
|
}
|
|
|
|
func (r *customVerificationRow) dest() []any {
|
|
return []any{
|
|
&r.mark.ID, &r.mark.VerifierBotID, &r.peerType, &r.mark.Peer.ID,
|
|
&r.mark.IconDocumentID, &r.mark.Description, &r.mark.GrantedByUserID,
|
|
&r.mark.CreatedAt, &r.mark.UpdatedAt, &r.mark.Version,
|
|
}
|
|
}
|
|
|
|
func (r *customVerificationRow) value() domain.CustomVerification {
|
|
mark := r.mark
|
|
mark.Peer.Type = domain.PeerType(r.peerType)
|
|
mark.CreatedAt = mark.CreatedAt.UTC()
|
|
mark.UpdatedAt = mark.UpdatedAt.UTC()
|
|
return mark
|
|
}
|
|
|
|
func scanCustomVerification(row pgx.Row) (domain.CustomVerification, error) {
|
|
var r customVerificationRow
|
|
if err := row.Scan(r.dest()...); err != nil {
|
|
return domain.CustomVerification{}, err
|
|
}
|
|
return r.value(), nil
|
|
}
|
|
|
|
// customVerificationTx reads one verifier's mark on a peer, optionally locking it
|
|
// so a concurrent grant of the same pair serialises behind this one.
|
|
func customVerificationTx(ctx context.Context, db sqlcgen.DBTX, verifierBotID int64, peer domain.Peer, forUpdate bool) (domain.CustomVerification, error) {
|
|
query := `
|
|
SELECT ` + customVerificationColumnList + `
|
|
FROM custom_verifications
|
|
WHERE verifier_bot_id = $1 AND peer_type = $2 AND peer_id = $3`
|
|
if forUpdate {
|
|
query += `
|
|
FOR UPDATE`
|
|
}
|
|
mark, err := scanCustomVerification(db.QueryRow(ctx, query,
|
|
verifierBotID, string(peer.Type), peer.ID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CustomVerification{}, domain.ErrCustomVerificationNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.CustomVerification{}, fmt.Errorf("get custom verification: %w", err)
|
|
}
|
|
return mark, nil
|
|
}
|
|
|
|
func countCustomVerificationsTx(ctx context.Context, db sqlcgen.DBTX, verifierBotID int64) (int, error) {
|
|
var count int
|
|
if err := db.QueryRow(ctx, `
|
|
SELECT count(*) FROM custom_verifications WHERE verifier_bot_id = $1`,
|
|
verifierBotID).Scan(&count); err != nil {
|
|
return 0, fmt.Errorf("count custom verifications: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// customVerificationRequestRow adapts the application projection: the enum
|
|
// columns arrive as text and approved_at / rejected_at are nullable.
|
|
type customVerificationRequestRow struct {
|
|
req domain.CustomVerificationRequest
|
|
peerType string
|
|
status string
|
|
approvedAt *time.Time
|
|
rejectedAt *time.Time
|
|
}
|
|
|
|
func (r *customVerificationRequestRow) dest() []any {
|
|
return []any{
|
|
&r.req.ID, &r.req.VerifierBotID, &r.req.ApplicantUserID, &r.peerType,
|
|
&r.req.Peer.ID, &r.req.PeerTitle, &r.req.PeerUsername, &r.req.Reason,
|
|
&r.req.RequestedDescription, &r.status, &r.req.DecidedBy,
|
|
&r.req.DecisionReason, &r.req.InternalNote, &r.req.CorrelationID,
|
|
&r.req.CreatedAt, &r.req.UpdatedAt, &r.approvedAt, &r.rejectedAt,
|
|
&r.req.Version,
|
|
}
|
|
}
|
|
|
|
func (r *customVerificationRequestRow) value() domain.CustomVerificationRequest {
|
|
req := r.req
|
|
req.Peer.Type = domain.PeerType(r.peerType)
|
|
req.Status = domain.CustomVerificationRequestStatus(r.status)
|
|
req.CreatedAt = req.CreatedAt.UTC()
|
|
req.UpdatedAt = req.UpdatedAt.UTC()
|
|
if r.approvedAt != nil {
|
|
req.ApprovedAt = r.approvedAt.UTC()
|
|
}
|
|
if r.rejectedAt != nil {
|
|
req.RejectedAt = r.rejectedAt.UTC()
|
|
}
|
|
return req
|
|
}
|
|
|
|
func scanCustomVerificationRequest(row pgx.Row) (domain.CustomVerificationRequest, error) {
|
|
var r customVerificationRequestRow
|
|
if err := row.Scan(r.dest()...); err != nil {
|
|
return domain.CustomVerificationRequest{}, err
|
|
}
|
|
return r.value(), nil
|
|
}
|
|
|
|
// lockCustomVerificationRequestTx reads the application for mutation. FOR UPDATE
|
|
// plus the version guard on the following UPDATE is what serialises two
|
|
// reviewers: the second one blocks here and then sees the bumped version.
|
|
func lockCustomVerificationRequestTx(ctx context.Context, tx pgx.Tx, requestID int64) (domain.CustomVerificationRequest, error) {
|
|
req, err := scanCustomVerificationRequest(tx.QueryRow(ctx, `
|
|
SELECT `+customVerificationRequestColumnList+`
|
|
FROM custom_verification_requests
|
|
WHERE id = $1
|
|
FOR UPDATE`, requestID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.CustomVerificationRequest{}, domain.ErrCustomVerificationRequestNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.CustomVerificationRequest{}, fmt.Errorf("lock custom verification request: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// customVerificationDecisionNeedsApply reports whether a decision moves the mark
|
|
// and therefore needs the callback: approving grants it, revoking removes it,
|
|
// and a rejection never had one to move.
|
|
func customVerificationDecisionNeedsApply(status domain.CustomVerificationRequestStatus) bool {
|
|
return status == domain.CustomVerificationApproved || status == domain.CustomVerificationRevoked
|
|
}
|
|
|
|
// customVerificationDecisionState projects a decision onto the stored row.
|
|
//
|
|
// The approved_at / rejected_at stamps are not free-form: 0155 pairs each with
|
|
// its status ("(status = 'approved') = (approved_at IS NOT NULL)"), so a
|
|
// revocation has to clear approved_at as it leaves the approved state. The
|
|
// application's history of having been approved lives in the status itself --
|
|
// revoked is reachable only from approved.
|
|
func customVerificationDecisionState(current domain.CustomVerificationRequest, status domain.CustomVerificationRequestStatus, decidedBy, reason, note string, now time.Time) domain.CustomVerificationRequest {
|
|
next := current
|
|
next.Status = status
|
|
next.DecidedBy = decidedBy
|
|
next.DecisionReason = reason
|
|
next.InternalNote = note
|
|
next.ApprovedAt = time.Time{}
|
|
next.RejectedAt = time.Time{}
|
|
switch status {
|
|
case domain.CustomVerificationApproved:
|
|
next.ApprovedAt = now
|
|
case domain.CustomVerificationRejected:
|
|
next.RejectedAt = now
|
|
}
|
|
next.Version = current.Version + 1
|
|
if now.After(next.UpdatedAt) {
|
|
next.UpdatedAt = now
|
|
}
|
|
return next
|
|
}
|
|
|
|
func normalizeCustomVerificationRequest(req domain.CustomVerificationRequest) domain.CustomVerificationRequest {
|
|
req.PeerTitle = strings.TrimSpace(req.PeerTitle)
|
|
req.PeerUsername = domain.NormalizeUsername(req.PeerUsername)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.RequestedDescription = strings.TrimSpace(req.RequestedDescription)
|
|
req.DecidedBy = strings.TrimSpace(req.DecidedBy)
|
|
req.DecisionReason = strings.TrimSpace(req.DecisionReason)
|
|
req.InternalNote = strings.TrimSpace(req.InternalNote)
|
|
req.CorrelationID = strings.TrimSpace(req.CorrelationID)
|
|
return req
|
|
}
|
|
|
|
// botVerifierSettingsColumnsFit reports whether the verifier text fits the
|
|
// columns in bytes, which the rune-counting domain Validate cannot answer.
|
|
func botVerifierSettingsColumnsFit(settings domain.BotVerifierSettings) bool {
|
|
return len(settings.CompanyName) <= maxVerifierCompanyBytes &&
|
|
len(settings.DefaultDescription) <= maxVerifierDescriptionBytes &&
|
|
len(settings.GrantReason) <= maxVerifierGrantReasonBytes
|
|
}
|
|
|
|
// validateCustomVerificationRequestColumns guards the octet_length CHECKs on
|
|
// custom_verification_requests, so an over-long snapshot is a domain error
|
|
// rather than a constraint violation from the driver.
|
|
func validateCustomVerificationRequestColumns(req domain.CustomVerificationRequest) error {
|
|
if len(req.PeerTitle) > maxCustomVerificationTitleBytes ||
|
|
len(req.PeerUsername) > maxCustomVerificationUsernameBytes ||
|
|
len(req.Reason) > maxCustomVerificationReasonBytes ||
|
|
len(req.RequestedDescription) > maxCustomVerificationInputBytes ||
|
|
len(req.DecidedBy) > maxCustomVerificationDecidedByBytes ||
|
|
len(req.DecisionReason) > maxCustomVerificationDecisionBytes ||
|
|
len(req.InternalNote) > maxCustomVerificationNoteBytes ||
|
|
len(req.CorrelationID) > maxCustomVerificationCorrelationBytes {
|
|
return domain.ErrCustomVerificationRequestInvalid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// botVerificationTimeArg keeps a zero time out of a NOT NULL-paired column: the
|
|
// schema wants NULL, and pgx would otherwise write year 1.
|
|
func botVerificationTimeArg(at time.Time) any {
|
|
if at.IsZero() {
|
|
return nil
|
|
}
|
|
return at.UTC()
|
|
}
|
|
|
|
// botVerificationPeerArrays turns the batch input into the two parallel arrays
|
|
// the single projection query unnests, dropping unverifiable peers and
|
|
// duplicates so one peer cannot cost two rows.
|
|
func botVerificationPeerArrays(peers []domain.Peer) ([]string, []int64) {
|
|
types := make([]string, 0, len(peers))
|
|
ids := make([]int64, 0, len(peers))
|
|
seen := make(map[domain.Peer]struct{}, len(peers))
|
|
for _, peer := range peers {
|
|
if !validBotVerificationPeer(peer) {
|
|
continue
|
|
}
|
|
if _, dup := seen[peer]; dup {
|
|
continue
|
|
}
|
|
seen[peer] = struct{}{}
|
|
types = append(types, string(peer.Type))
|
|
ids = append(ids, peer.ID)
|
|
}
|
|
return types, ids
|
|
}
|
|
|
|
// validBotVerificationPeer mirrors the peer_type CHECK: only users and channels
|
|
// carry a third-party mark.
|
|
func validBotVerificationPeer(peer domain.Peer) bool {
|
|
return botVerificationPeerType(peer.Type) && peer.ID > 0
|
|
}
|
|
|
|
func botVerificationPeerType(peerType domain.PeerType) bool {
|
|
return peerType == domain.PeerTypeUser || peerType == domain.PeerTypeChannel
|
|
}
|
|
|
|
func botVerificationLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return defaultBotVerificationListLimit
|
|
}
|
|
if limit > maxBotVerificationListLimit {
|
|
return maxBotVerificationListLimit
|
|
}
|
|
return limit
|
|
}
|
|
|
|
// parseBotVerificationQuery splits an admin search term into its two shapes: a
|
|
// number addresses a row id or a peer id, anything else is text. Telegram
|
|
// usernames never start with a digit, so the two shapes cannot collide.
|
|
func parseBotVerificationQuery(query string) (numeric int64, isNumeric bool, text string) {
|
|
query = strings.TrimSpace(query)
|
|
query = strings.TrimPrefix(query, "@")
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return 0, false, ""
|
|
}
|
|
if id, err := strconv.ParseInt(query, 10, 64); err == nil && id > 0 {
|
|
return id, true, ""
|
|
}
|
|
return 0, false, strings.ToLower(query)
|
|
}
|