Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
1342 lines
51 KiB
Go
1342 lines
51 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
"telesrv/internal/store/postgres/sqlcgen"
|
|
)
|
|
|
|
// VerificationStore is the PostgreSQL implementation of the official platform
|
|
// verification store: applications filed through @verifybot, the immutable
|
|
// per-application history and the applicant-notification outbox.
|
|
//
|
|
// Three properties are load bearing and every method below exists to keep them:
|
|
//
|
|
// - "approved implies target verified". The status change, the history row, the
|
|
// outbox row and the peer flag are written in one transaction. The flag is set
|
|
// by the caller-supplied applyVerified callback, which runs inside that
|
|
// transaction (see VerificationTxFromContext), so a callback failure rolls the
|
|
// decision back and a crash can never leave an approved application whose
|
|
// target is not verified.
|
|
// - Exactly one decision per application. Every mutation is guarded by
|
|
// WHERE id = $1 AND version = $2 on top of a SELECT ... FOR UPDATE, so two
|
|
// reviewers deciding the same application concurrently produce one winner and
|
|
// domain.ErrVerificationVersionConflict for the loser.
|
|
// - Exactly one notification per decision. The outbox unique key is
|
|
// (application_id, kind), and a repeated decision on an already-decided
|
|
// application is a no-op reporting changed=false rather than a second message.
|
|
//
|
|
// Status transitions are never re-implemented in SQL: they all go through
|
|
// domain.CanTransitionVerificationStatus, which the bot dialog validates against
|
|
// too. The history table has no UPDATE or DELETE path in this file by design.
|
|
type VerificationStore struct {
|
|
db sqlcgen.DBTX
|
|
}
|
|
|
|
// NewVerificationStore builds the store on a pgx pool or transaction.
|
|
func NewVerificationStore(db sqlcgen.DBTX) *VerificationStore {
|
|
return &VerificationStore{db: db}
|
|
}
|
|
|
|
var _ store.VerificationStore = (*VerificationStore)(nil)
|
|
|
|
const (
|
|
defaultVerificationListLimit = 50
|
|
maxVerificationListLimit = 200
|
|
// maxVerificationUsernameBytes mirrors the octet_length CHECK on
|
|
// target_username. The domain request bounds the title but not the username
|
|
// snapshot, so the store guards the column itself.
|
|
maxVerificationUsernameBytes = 64
|
|
// maxVerificationOutboxErrorBytes mirrors the octet_length CHECK on
|
|
// verification_notification_outbox.last_error.
|
|
maxVerificationOutboxErrorBytes = 1024
|
|
)
|
|
|
|
// Constraint names the store maps onto domain errors. They are the schema's
|
|
// invariants, so a race that slips past the pre-checks still reports the same
|
|
// error as the check would have.
|
|
const (
|
|
verificationActiveTargetConstraint = "verification_applications_active_target_idx"
|
|
verificationApplicantDraftConstraint = "verification_applications_applicant_draft_idx"
|
|
verificationNotificationConstraint = "verification_notification_once"
|
|
)
|
|
|
|
// verificationApplicationColumnList is the application projection shared by every
|
|
// reader, in scan order.
|
|
const verificationApplicationColumnList = `id, applicant_user_id, target_type, target_id,
|
|
target_title, target_username, target_access_hash, category, description,
|
|
official_website, social_links, press_links, additional_note, status,
|
|
reviewer_admin_id, decision_reason, internal_note, correlation_id,
|
|
created_at, updated_at, submitted_at, reviewed_at, version`
|
|
|
|
// verificationApplicationJoinColumns is the same projection qualified for the
|
|
// outbox join, kept in sync by construction rather than by hand.
|
|
var verificationApplicationJoinColumns = prefixVerificationColumns("a.")
|
|
|
|
func prefixVerificationColumns(alias string) string {
|
|
parts := strings.Split(verificationApplicationColumnList, ",")
|
|
for i, part := range parts {
|
|
parts[i] = alias + strings.TrimSpace(part)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// verificationTxKey carries the transaction driving a decision into the
|
|
// applyVerified / clearVerified callback.
|
|
type verificationTxKey struct{}
|
|
|
|
func verificationTxContext(ctx context.Context, tx pgx.Tx) context.Context {
|
|
return context.WithValue(ctx, verificationTxKey{}, tx)
|
|
}
|
|
|
|
// VerificationTxFromContext returns the transaction that is currently deciding a
|
|
// verification application, if the context came from
|
|
// DecideVerificationApplication or RevokeVerification.
|
|
//
|
|
// This is how "approved implies target verified" is kept atomic across two
|
|
// stores: the applyVerified / clearVerified callback must write the peer flag
|
|
// through this handle (for example postgres.NewUserStore(tx).SetVerified) instead
|
|
// of its own pool connection. A callback that ignores it writes in a separate
|
|
// transaction, and the flag would survive a rollback of the decision.
|
|
func VerificationTxFromContext(ctx context.Context) (sqlcgen.DBTX, bool) {
|
|
tx, ok := ctx.Value(verificationTxKey{}).(pgx.Tx)
|
|
if !ok || tx == nil {
|
|
return nil, false
|
|
}
|
|
return tx, true
|
|
}
|
|
|
|
// verificationNow is the single clock for the store. Timestamps are truncated to
|
|
// the timestamptz resolution so a value written here reads back identically.
|
|
func verificationNow() time.Time {
|
|
return time.Now().UTC().Truncate(time.Microsecond)
|
|
}
|
|
|
|
// CreateVerificationDraft opens a draft for the applicant/target pair.
|
|
//
|
|
// The applicant's own draft wins over everything else: the bot dialog is a single
|
|
// conversation and verification_applications_applicant_draft_idx allows exactly
|
|
// one, so an applicant who already has one gets it back with created=false and
|
|
// the dialog resumes. Only then is the target checked, and an application already
|
|
// occupying it reports domain.ErrVerificationApplicationExists.
|
|
func (s *VerificationStore) CreateVerificationDraft(ctx context.Context, req domain.SubmitVerificationApplicationRequest) (domain.VerificationApplication, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, false, fmt.Errorf("verification store is not configured")
|
|
}
|
|
req.TargetTitle = strings.TrimSpace(req.TargetTitle)
|
|
req.TargetUsername = domain.NormalizeUsername(req.TargetUsername)
|
|
req.CorrelationID = strings.TrimSpace(req.CorrelationID)
|
|
req.Draft = req.Draft.Normalize()
|
|
if err := validateVerificationDraftRequest(req); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
var app domain.VerificationApplication
|
|
created := false
|
|
err := withTx(ctx, s.db, "create verification draft", func(tx pgx.Tx) error {
|
|
// Both locks are taken before the pre-checks so two concurrent bot dialogs
|
|
// serialise instead of racing on the partial unique indexes.
|
|
if err := lockVerificationApplicant(ctx, tx, req.ApplicantUserID); err != nil {
|
|
return err
|
|
}
|
|
if err := lockVerificationTarget(ctx, tx, req.TargetType, req.TargetID); err != nil {
|
|
return err
|
|
}
|
|
existing, err := verificationDraftForApplicantTx(ctx, tx, req.ApplicantUserID)
|
|
switch {
|
|
case err == nil:
|
|
app = existing
|
|
return nil
|
|
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
|
default:
|
|
return err
|
|
}
|
|
switch _, err := activeVerificationApplicationTx(ctx, tx, req.TargetType, req.TargetID); {
|
|
case err == nil:
|
|
return domain.ErrVerificationApplicationExists
|
|
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
|
default:
|
|
return err
|
|
}
|
|
now := verificationNow()
|
|
inserted, err := scanVerificationApplication(tx.QueryRow(ctx, `
|
|
INSERT INTO verification_applications (
|
|
applicant_user_id, target_type, target_id, target_title, target_username,
|
|
category, description, official_website, social_links, press_links,
|
|
additional_note, status, correlation_id, created_at, updated_at, version
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'draft',$12,$13,$13,1)
|
|
RETURNING `+verificationApplicationColumnList,
|
|
req.ApplicantUserID, string(req.TargetType), req.TargetID, req.TargetTitle,
|
|
req.TargetUsername, req.Draft.Category, req.Draft.Description,
|
|
req.Draft.OfficialWebsite, verificationLinksArg(req.Draft.SocialLinks),
|
|
verificationLinksArg(req.Draft.PressLinks), req.Draft.AdditionalNote,
|
|
req.CorrelationID, now,
|
|
))
|
|
if err != nil {
|
|
if isUniqueConstraint(err, verificationActiveTargetConstraint) ||
|
|
isUniqueConstraint(err, verificationApplicantDraftConstraint) {
|
|
return domain.ErrVerificationApplicationExists
|
|
}
|
|
return fmt.Errorf("insert verification application: %w", err)
|
|
}
|
|
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
|
|
applicationID: inserted.ID,
|
|
kind: domain.VerificationEventCreated,
|
|
to: domain.VerificationStatusDraft,
|
|
correlationID: inserted.CorrelationID,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
app = inserted
|
|
created = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
return app, created, nil
|
|
}
|
|
|
|
// SaveVerificationDraft rewrites the applicant-supplied payload of a draft.
|
|
func (s *VerificationStore) SaveVerificationDraft(ctx context.Context, applicationID int64, version int64, draft domain.VerificationDraftInput) (domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
|
|
}
|
|
if applicationID <= 0 || version <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
draft = draft.Normalize()
|
|
if err := draft.ValidateDraft(); err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
var app domain.VerificationApplication
|
|
err := withTx(ctx, s.db, "save verification draft", func(tx pgx.Tx) error {
|
|
current, err := lockVerificationApplicationTx(ctx, tx, applicationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Version != version {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if !current.Editable() {
|
|
return domain.ErrVerificationStatusInvalid
|
|
}
|
|
now := verificationNow()
|
|
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
|
|
UPDATE verification_applications
|
|
SET category = $3,
|
|
description = $4,
|
|
official_website = $5,
|
|
social_links = $6,
|
|
press_links = $7,
|
|
additional_note = $8,
|
|
version = version + 1,
|
|
updated_at = GREATEST(updated_at, $9)
|
|
WHERE id = $1 AND version = $2
|
|
RETURNING `+verificationApplicationColumnList,
|
|
applicationID, version, draft.Category, draft.Description,
|
|
draft.OfficialWebsite, verificationLinksArg(draft.SocialLinks),
|
|
verificationLinksArg(draft.PressLinks), draft.AdditionalNote, now,
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("update verification draft: %w", err)
|
|
}
|
|
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
|
|
applicationID: applicationID,
|
|
kind: domain.VerificationEventUpdated,
|
|
from: current.Status,
|
|
to: updated.Status,
|
|
correlationID: updated.CorrelationID,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
app = updated
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// SubmitVerificationApplication moves an application into the review queue and
|
|
// stamps submitted_at.
|
|
//
|
|
// The stored payload is validated against the submission bar here rather than
|
|
// only in the bot: an incomplete draft must not be able to reach a reviewer
|
|
// through any path. Coming back from in_review (the domain machine allows it)
|
|
// releases the claim and keeps the original submitted_at.
|
|
func (s *VerificationStore) SubmitVerificationApplication(ctx context.Context, applicationID int64, version int64) (domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
|
|
}
|
|
if applicationID <= 0 || version <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
var app domain.VerificationApplication
|
|
err := withTx(ctx, s.db, "submit verification application", func(tx pgx.Tx) error {
|
|
current, err := lockVerificationApplicationTx(ctx, tx, applicationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Version != version {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if !domain.CanTransitionVerificationStatus(current.Status, domain.VerificationStatusSubmitted) {
|
|
return domain.ErrVerificationStatusInvalid
|
|
}
|
|
if err := verificationDraftOf(current).ValidateForSubmission(); err != nil {
|
|
return err
|
|
}
|
|
now := verificationNow()
|
|
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
|
|
UPDATE verification_applications
|
|
SET status = 'submitted',
|
|
reviewer_admin_id = '',
|
|
submitted_at = COALESCE(submitted_at, $3),
|
|
version = version + 1,
|
|
updated_at = GREATEST(updated_at, $3)
|
|
WHERE id = $1 AND version = $2
|
|
RETURNING `+verificationApplicationColumnList,
|
|
applicationID, version, now,
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("submit verification application: %w", err)
|
|
}
|
|
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
|
|
applicationID: applicationID,
|
|
kind: domain.VerificationEventSubmitted,
|
|
from: current.Status,
|
|
to: updated.Status,
|
|
correlationID: updated.CorrelationID,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
app = updated
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// CancelVerificationApplication withdraws an active application on the
|
|
// applicant's behalf. The reason is the applicant's, so it lands on the history
|
|
// row and never in decision_reason, which stays reviewer-owned.
|
|
func (s *VerificationStore) CancelVerificationApplication(ctx context.Context, applicationID int64, version int64, reason string) (domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
|
|
}
|
|
reason = strings.TrimSpace(reason)
|
|
if applicationID <= 0 || version <= 0 ||
|
|
utf8.RuneCountInString(reason) > domain.MaxVerificationReasonLength {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
var app domain.VerificationApplication
|
|
err := withTx(ctx, s.db, "cancel verification application", func(tx pgx.Tx) error {
|
|
current, err := lockVerificationApplicationTx(ctx, tx, applicationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Version != version {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if !domain.CanTransitionVerificationStatus(current.Status, domain.VerificationStatusCancelled) {
|
|
return domain.ErrVerificationStatusInvalid
|
|
}
|
|
now := verificationNow()
|
|
// A cancelled draft was never submitted, but the schema requires
|
|
// submitted_at on every non-draft row, so the withdrawal time stands in.
|
|
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
|
|
UPDATE verification_applications
|
|
SET status = 'cancelled',
|
|
submitted_at = COALESCE(submitted_at, $3),
|
|
version = version + 1,
|
|
updated_at = GREATEST(updated_at, $3)
|
|
WHERE id = $1 AND version = $2
|
|
RETURNING `+verificationApplicationColumnList,
|
|
applicationID, version, now,
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("cancel verification application: %w", err)
|
|
}
|
|
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
|
|
applicationID: applicationID,
|
|
kind: domain.VerificationEventCancelled,
|
|
from: current.Status,
|
|
to: updated.Status,
|
|
reason: reason,
|
|
correlationID: updated.CorrelationID,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
app = updated
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// ClaimVerificationApplication assigns a reviewer and moves the application to
|
|
// in_review. reviewed_at stays NULL: a claim is not a decision, and the schema
|
|
// pairs reviewed_at with a decided status.
|
|
func (s *VerificationStore) ClaimVerificationApplication(ctx context.Context, decision domain.VerificationDecision) (domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
|
|
}
|
|
decision = normalizeVerificationDecision(decision)
|
|
if err := decision.Validate(); err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
var app domain.VerificationApplication
|
|
err := withTx(ctx, s.db, "claim verification application", func(tx pgx.Tx) error {
|
|
current, err := lockVerificationApplicationTx(ctx, tx, decision.ApplicationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Version != decision.Version {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if !domain.CanTransitionVerificationStatus(current.Status, domain.VerificationStatusInReview) {
|
|
return domain.ErrVerificationStatusInvalid
|
|
}
|
|
now := verificationNow()
|
|
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
|
|
UPDATE verification_applications
|
|
SET status = 'in_review',
|
|
reviewer_admin_id = $3,
|
|
version = version + 1,
|
|
updated_at = GREATEST(updated_at, $4)
|
|
WHERE id = $1 AND version = $2
|
|
RETURNING `+verificationApplicationColumnList,
|
|
decision.ApplicationID, decision.Version, decision.Reviewer, now,
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("claim verification application: %w", err)
|
|
}
|
|
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
|
|
applicationID: decision.ApplicationID,
|
|
kind: domain.VerificationEventClaimed,
|
|
from: current.Status,
|
|
to: updated.Status,
|
|
actor: decision.Reviewer,
|
|
reason: decision.Reason,
|
|
note: decision.InternalNote,
|
|
correlationID: decision.CorrelationID,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
app = updated
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// DecideVerificationApplication records an approval or a rejection.
|
|
//
|
|
// Order of checks matters and is the deterministic part of two reviewers acting
|
|
// at once: the version is compared first, so the loser of the race always gets
|
|
// domain.ErrVerificationVersionConflict rather than a silent no-op. A caller that
|
|
// re-issues the same decision with the current version (a retry after it already
|
|
// committed) sees the decided application with changed=false, no second history
|
|
// row and no second outbox row.
|
|
//
|
|
// On approve the peer flag is written by applyVerified inside this transaction,
|
|
// so a callback error rolls the whole decision back and "approved implies target
|
|
// verified" holds even across a crash.
|
|
func (s *VerificationStore) DecideVerificationApplication(ctx context.Context, decision domain.VerificationDecision, approve bool, applyVerified func(ctx context.Context, app domain.VerificationApplication) error) (domain.VerificationApplication, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, false, fmt.Errorf("verification store is not configured")
|
|
}
|
|
decision = normalizeVerificationDecision(decision)
|
|
target := domain.VerificationStatusRejected
|
|
kind := domain.VerificationEventRejected
|
|
if approve {
|
|
target = domain.VerificationStatusApproved
|
|
kind = domain.VerificationEventApproved
|
|
if err := decision.Validate(); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if applyVerified == nil {
|
|
// Approving without a way to set the flag is exactly the state this
|
|
// store exists to make impossible.
|
|
return domain.VerificationApplication{}, false, fmt.Errorf("verification approval requires an applyVerified callback")
|
|
}
|
|
} else if err := decision.ValidateWithReason(); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
var app domain.VerificationApplication
|
|
changed := false
|
|
err := withTx(ctx, s.db, "decide verification application", func(tx pgx.Tx) error {
|
|
current, err := lockVerificationApplicationTx(ctx, tx, decision.ApplicationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Version != decision.Version {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if current.Status == target {
|
|
// Already decided this way: keep the record and the single
|
|
// notification, report that nothing moved.
|
|
app = current
|
|
return nil
|
|
}
|
|
if !domain.CanTransitionVerificationStatus(current.Status, target) {
|
|
return domain.ErrVerificationStatusInvalid
|
|
}
|
|
now := verificationNow()
|
|
updated, err := scanVerificationApplication(tx.QueryRow(ctx, `
|
|
UPDATE verification_applications
|
|
SET status = $3,
|
|
reviewer_admin_id = $4,
|
|
reviewed_at = $5,
|
|
decision_reason = $6,
|
|
internal_note = $7,
|
|
submitted_at = COALESCE(submitted_at, $5),
|
|
version = version + 1,
|
|
updated_at = GREATEST(updated_at, $5)
|
|
WHERE id = $1 AND version = $2
|
|
RETURNING `+verificationApplicationColumnList,
|
|
decision.ApplicationID, decision.Version, string(target),
|
|
decision.Reviewer, now, decision.Reason, decision.InternalNote,
|
|
))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrVerificationVersionConflict
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("decide verification application: %w", err)
|
|
}
|
|
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
|
|
applicationID: updated.ID,
|
|
kind: kind,
|
|
from: current.Status,
|
|
to: updated.Status,
|
|
actor: decision.Reviewer,
|
|
reason: decision.Reason,
|
|
note: decision.InternalNote,
|
|
correlationID: decision.CorrelationID,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := insertVerificationNotificationTx(ctx, tx, updated.ID,
|
|
updated.ApplicantUserID, string(target), now); err != nil {
|
|
return err
|
|
}
|
|
if approve {
|
|
if err := applyVerified(verificationTxContext(ctx, tx), updated); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
app = updated
|
|
changed = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
return app, changed, nil
|
|
}
|
|
|
|
// RevokeVerification clears the platform flag of a previously approved target.
|
|
//
|
|
// The application stays approved: it is the audit record of a decision that did
|
|
// happen. The revocation is its own history row plus its own outbox kind, and
|
|
// that outbox row is what makes a repeated revocation a no-op with changed=false.
|
|
//
|
|
// A target with no approved application on file (verified by an earlier
|
|
// deployment, or by an operator by hand) is still cleared through the callback,
|
|
// because leaving a flag standing is worse than a missing audit row; there is
|
|
// then nothing to deduplicate against, so such a call always reports
|
|
// changed=true.
|
|
func (s *VerificationStore) RevokeVerification(ctx context.Context, req domain.VerificationRevocation, clearVerified func(ctx context.Context, target domain.Peer) error) (domain.VerificationApplication, bool, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, false, fmt.Errorf("verification store is not configured")
|
|
}
|
|
req.Reviewer = strings.TrimSpace(req.Reviewer)
|
|
req.Reason = strings.TrimSpace(req.Reason)
|
|
req.InternalNote = strings.TrimSpace(req.InternalNote)
|
|
req.CorrelationID = strings.TrimSpace(req.CorrelationID)
|
|
if err := req.Validate(); err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
if len(req.CorrelationID) > domain.MaxVerificationCorrelationLen {
|
|
return domain.VerificationApplication{}, false, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
if clearVerified == nil {
|
|
return domain.VerificationApplication{}, false, fmt.Errorf("verification revocation requires a clearVerified callback")
|
|
}
|
|
var app domain.VerificationApplication
|
|
changed := false
|
|
err := withTx(ctx, s.db, "revoke verification", func(tx pgx.Tx) error {
|
|
// There may be no application row to lock, so the target itself is the
|
|
// serialisation point for concurrent revocations.
|
|
if err := lockVerificationTarget(ctx, tx, req.TargetType, req.TargetID); err != nil {
|
|
return err
|
|
}
|
|
var current domain.VerificationApplication
|
|
switch found, err := newestApprovedVerificationApplicationTx(ctx, tx, req.TargetType, req.TargetID); {
|
|
case err == nil:
|
|
current = found
|
|
done, err := verificationNotificationExistsTx(ctx, tx, current.ID, string(domain.VerificationEventRevoked))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if done {
|
|
app = current
|
|
return nil
|
|
}
|
|
case errors.Is(err, domain.ErrVerificationApplicationNotFound):
|
|
default:
|
|
return err
|
|
}
|
|
if err := clearVerified(verificationTxContext(ctx, tx),
|
|
domain.Peer{Type: req.TargetType.PeerType(), ID: req.TargetID}); err != nil {
|
|
return err
|
|
}
|
|
if current.ID != 0 {
|
|
now := verificationNow()
|
|
if err := insertVerificationEventTx(ctx, tx, verificationEventInput{
|
|
applicationID: current.ID,
|
|
kind: domain.VerificationEventRevoked,
|
|
from: current.Status,
|
|
to: current.Status,
|
|
actor: req.Reviewer,
|
|
reason: req.Reason,
|
|
note: req.InternalNote,
|
|
correlationID: req.CorrelationID,
|
|
createdAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := insertVerificationNotificationTx(ctx, tx, current.ID,
|
|
current.ApplicantUserID, string(domain.VerificationEventRevoked), now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
app = current
|
|
changed = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, false, err
|
|
}
|
|
return app, changed, nil
|
|
}
|
|
|
|
// VerificationApplication reads one application by id.
|
|
func (s *VerificationStore) VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
|
|
}
|
|
if applicationID <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
return verificationApplicationByID(ctx, s.db, applicationID)
|
|
}
|
|
|
|
// ActiveVerificationApplicationForTarget returns the live application occupying a
|
|
// target, if any.
|
|
func (s *VerificationStore) ActiveVerificationApplicationForTarget(ctx context.Context, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
|
|
}
|
|
if !target.Valid() || targetID <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationTargetInvalid
|
|
}
|
|
return activeVerificationApplicationTx(ctx, s.db, target, targetID)
|
|
}
|
|
|
|
// VerificationDraftForApplicant returns the applicant's open draft, if any.
|
|
func (s *VerificationStore) VerificationDraftForApplicant(ctx context.Context, applicantUserID int64) (domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
|
|
}
|
|
if applicantUserID <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
return verificationDraftForApplicantTx(ctx, s.db, applicantUserID)
|
|
}
|
|
|
|
// ListVerificationApplications is the review-queue query.
|
|
//
|
|
// Paging is keyset over (created_at DESC, id DESC), the order
|
|
// verification_applications_queue_idx is built for: filter.Until and
|
|
// filter.BeforeID carry the last row of the previous page, filter.CreatedAt is
|
|
// the inclusive lower bound of the date range. Query matches an application id or
|
|
// a peer id when it is numeric and otherwise prefix-matches
|
|
// lower(target_username), which is what verification_applications_username_idx
|
|
// indexes.
|
|
func (s *VerificationStore) ListVerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("verification store is not configured")
|
|
}
|
|
statuses, err := verificationFilterStatuses(filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if filter.TargetType != "" && !filter.TargetType.Valid() {
|
|
return nil, domain.ErrVerificationTargetInvalid
|
|
}
|
|
if len(filter.Reviewer) > domain.MaxVerificationReviewerLength {
|
|
return nil, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
limit := verificationLimit(filter.Limit)
|
|
numeric, isNumeric, prefix := parseVerificationQuery(filter.Query)
|
|
var since, until any
|
|
if !filter.CreatedAt.IsZero() {
|
|
since = filter.CreatedAt.UTC()
|
|
}
|
|
if !filter.Until.IsZero() {
|
|
until = filter.Until.UTC()
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+verificationApplicationColumnList+`
|
|
FROM verification_applications
|
|
WHERE (cardinality($1::text[]) = 0 OR status = ANY($1::text[]))
|
|
AND ($2 = '' OR target_type = $2)
|
|
AND ($3 = '' OR reviewer_admin_id = $3)
|
|
AND ($4::timestamptz IS NULL OR created_at >= $4::timestamptz)
|
|
AND ($5::timestamptz IS NULL OR (created_at, id) < ($5::timestamptz, $6))
|
|
AND ($5::timestamptz IS NOT NULL OR $6 = 0 OR id < $6)
|
|
AND (
|
|
NOT $7::boolean
|
|
OR ($8::boolean AND (id = $9::bigint OR target_id = $9::bigint))
|
|
OR (
|
|
NOT $8::boolean
|
|
AND target_username <> ''
|
|
AND lower(target_username) LIKE $10::text || '%'
|
|
)
|
|
)
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT $11`,
|
|
statuses, string(filter.TargetType), filter.Reviewer, since, until,
|
|
filter.BeforeID, prefix != "" || isNumeric, isNumeric, numeric,
|
|
escapeLike(prefix), limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list verification applications: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.VerificationApplication, 0, limit)
|
|
for rows.Next() {
|
|
app, err := scanVerificationApplication(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan verification application: %w", err)
|
|
}
|
|
out = append(out, app)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate verification applications: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// VerificationApplicationsForApplicant returns the applicant's own history,
|
|
// newest first, for the bot's /status command.
|
|
func (s *VerificationStore) VerificationApplicationsForApplicant(ctx context.Context, applicantUserID int64, limit int) ([]domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("verification store is not configured")
|
|
}
|
|
if applicantUserID <= 0 {
|
|
return nil, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
limit = verificationLimit(limit)
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT `+verificationApplicationColumnList+`
|
|
FROM verification_applications
|
|
WHERE applicant_user_id = $1
|
|
ORDER BY id DESC
|
|
LIMIT $2`, applicantUserID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list applicant verification applications: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.VerificationApplication, 0, limit)
|
|
for rows.Next() {
|
|
app, err := scanVerificationApplication(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan applicant verification application: %w", err)
|
|
}
|
|
out = append(out, app)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate applicant verification applications: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// VerificationStatusCounts is the queue summary. Statuses nobody is in are absent
|
|
// rather than zero, which a map read cannot tell apart anyway.
|
|
func (s *VerificationStore) VerificationStatusCounts(ctx context.Context) (domain.VerificationStatusCounts, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("verification store is not configured")
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT status, count(*)
|
|
FROM verification_applications
|
|
GROUP BY status`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("count verification applications: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make(domain.VerificationStatusCounts, 6)
|
|
for rows.Next() {
|
|
var status string
|
|
var count int64
|
|
if err := rows.Scan(&status, &count); err != nil {
|
|
return nil, fmt.Errorf("scan verification status count: %w", err)
|
|
}
|
|
out[domain.VerificationStatus(status)] = count
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate verification status counts: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// VerificationApplicationEvents returns the immutable history, newest first.
|
|
func (s *VerificationStore) VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("verification store is not configured")
|
|
}
|
|
if applicationID <= 0 {
|
|
return nil, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
limit = verificationLimit(limit)
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, application_id, kind, from_status, to_status, actor, reason, note,
|
|
correlation_id, created_at
|
|
FROM verification_application_events
|
|
WHERE application_id = $1
|
|
ORDER BY id DESC
|
|
LIMIT $2`, applicationID, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list verification application events: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]domain.VerificationApplicationEvent, 0, limit)
|
|
for rows.Next() {
|
|
var event domain.VerificationApplicationEvent
|
|
var kind, from, to string
|
|
if err := rows.Scan(&event.ID, &event.ApplicationID, &kind, &from, &to,
|
|
&event.Actor, &event.Reason, &event.Note, &event.CorrelationID,
|
|
&event.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("scan verification application event: %w", err)
|
|
}
|
|
event.Kind = domain.VerificationApplicationEventKind(kind)
|
|
event.FromStatus = domain.VerificationStatus(from)
|
|
event.ToStatus = domain.VerificationStatus(to)
|
|
event.CreatedAt = event.CreatedAt.UTC()
|
|
out = append(out, event)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate verification application events: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LastVerificationRejection returns the newest rejected application for the
|
|
// applicant/target pair, which is what the re-application cooldown is measured
|
|
// from.
|
|
func (s *VerificationStore) LastVerificationRejection(ctx context.Context, applicantUserID int64, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
|
|
if s == nil || s.db == nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("verification store is not configured")
|
|
}
|
|
if applicantUserID <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
if !target.Valid() || targetID <= 0 {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationTargetInvalid
|
|
}
|
|
app, err := scanVerificationApplication(s.db.QueryRow(ctx, `
|
|
SELECT `+verificationApplicationColumnList+`
|
|
FROM verification_applications
|
|
WHERE applicant_user_id = $1 AND target_type = $2 AND target_id = $3
|
|
AND status = 'rejected'
|
|
ORDER BY reviewed_at DESC NULLS LAST, id DESC
|
|
LIMIT 1`, applicantUserID, string(target), targetID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("get last verification rejection: %w", err)
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// PendingVerificationNotifications returns undelivered outbox rows, oldest first.
|
|
// The application travels with the row because the worker renders the message
|
|
// text from it and must not need a second round trip per notification.
|
|
func (s *VerificationStore) PendingVerificationNotifications(ctx context.Context, limit int) ([]store.VerificationNotification, error) {
|
|
if s == nil || s.db == nil {
|
|
return nil, fmt.Errorf("verification store is not configured")
|
|
}
|
|
limit = verificationLimit(limit)
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT o.id, o.application_id, o.recipient_user_id, o.kind, o.attempts,
|
|
`+verificationApplicationJoinColumns+`
|
|
FROM verification_notification_outbox o
|
|
JOIN verification_applications a ON a.id = o.application_id
|
|
WHERE o.delivered_at IS NULL
|
|
ORDER BY o.created_at, o.id
|
|
LIMIT $1`, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list pending verification notifications: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := make([]store.VerificationNotification, 0, limit)
|
|
for rows.Next() {
|
|
var item store.VerificationNotification
|
|
var row verificationApplicationRow
|
|
dest := append([]any{&item.ID, &item.ApplicationID, &item.RecipientUserID,
|
|
&item.Kind, &item.Attempts}, row.dest()...)
|
|
if err := rows.Scan(dest...); err != nil {
|
|
return nil, fmt.Errorf("scan pending verification notification: %w", err)
|
|
}
|
|
item.Application = row.application()
|
|
out = append(out, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate pending verification notifications: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// MarkVerificationNotificationDelivered closes an outbox row and appends the
|
|
// 'notified' history entry, so the application timeline records that the
|
|
// applicant was actually told. Closing an already-closed row is a no-op: the
|
|
// outbox is exactly-once, not at-least-once.
|
|
func (s *VerificationStore) MarkVerificationNotificationDelivered(ctx context.Context, id int64) error {
|
|
if s == nil || s.db == nil {
|
|
return fmt.Errorf("verification store is not configured")
|
|
}
|
|
if id <= 0 {
|
|
return domain.ErrVerificationApplicationInvalid
|
|
}
|
|
return withTx(ctx, s.db, "deliver verification notification", func(tx pgx.Tx) error {
|
|
var applicationID int64
|
|
var kind string
|
|
var deliveredAt *time.Time
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT application_id, kind, delivered_at
|
|
FROM verification_notification_outbox
|
|
WHERE id = $1
|
|
FOR UPDATE`, id).Scan(&applicationID, &kind, &deliveredAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrVerificationApplicationNotFound
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("lock verification notification: %w", err)
|
|
}
|
|
if deliveredAt != nil {
|
|
return nil
|
|
}
|
|
now := verificationNow()
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE verification_notification_outbox
|
|
SET delivered_at = $2, last_error = ''
|
|
WHERE id = $1`, id, now); err != nil {
|
|
return fmt.Errorf("deliver verification notification: %w", err)
|
|
}
|
|
app, err := verificationApplicationByID(ctx, tx, applicationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// reason carries the notification kind so the timeline says which message
|
|
// the applicant received.
|
|
return insertVerificationEventTx(ctx, tx, verificationEventInput{
|
|
applicationID: applicationID,
|
|
kind: domain.VerificationEventNotified,
|
|
from: app.Status,
|
|
to: app.Status,
|
|
reason: kind,
|
|
correlationID: app.CorrelationID,
|
|
createdAt: now,
|
|
})
|
|
})
|
|
}
|
|
|
|
// MarkVerificationNotificationFailed records a delivery attempt. The row stays
|
|
// pending, so a poisoned notification keeps its attempt count and last error
|
|
// instead of spinning without a trace.
|
|
func (s *VerificationStore) MarkVerificationNotificationFailed(ctx context.Context, id int64, reason string) error {
|
|
if s == nil || s.db == nil {
|
|
return fmt.Errorf("verification store is not configured")
|
|
}
|
|
if id <= 0 {
|
|
return domain.ErrVerificationApplicationInvalid
|
|
}
|
|
tag, err := s.db.Exec(ctx, `
|
|
UPDATE verification_notification_outbox
|
|
SET attempts = attempts + 1, last_error = $2
|
|
WHERE id = $1 AND delivered_at IS NULL`,
|
|
id, truncateVerificationError(reason))
|
|
if err != nil {
|
|
return fmt.Errorf("fail verification notification: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
// Either the row is gone or it is already delivered; both mean there is no
|
|
// pending attempt left to record.
|
|
var exists bool
|
|
if err := s.db.QueryRow(ctx, `
|
|
SELECT true FROM verification_notification_outbox WHERE id = $1`, id).Scan(&exists); errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.ErrVerificationApplicationNotFound
|
|
} else if err != nil {
|
|
return fmt.Errorf("get verification notification: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- helpers ----------------------------------------------------------------
|
|
|
|
// verificationApplicationRow adapts the projection onto the domain type: the
|
|
// enum columns arrive as text and submitted_at / reviewed_at are nullable.
|
|
type verificationApplicationRow struct {
|
|
app domain.VerificationApplication
|
|
targetType string
|
|
status string
|
|
submittedAt *time.Time
|
|
reviewedAt *time.Time
|
|
}
|
|
|
|
func (r *verificationApplicationRow) dest() []any {
|
|
return []any{
|
|
&r.app.ID, &r.app.ApplicantUserID, &r.targetType, &r.app.TargetID,
|
|
&r.app.TargetTitle, &r.app.TargetUsername, &r.app.TargetAccessHash,
|
|
&r.app.Category, &r.app.Description, &r.app.OfficialWebsite,
|
|
&r.app.SocialLinks, &r.app.PressLinks, &r.app.AdditionalNote, &r.status,
|
|
&r.app.ReviewerAdminID, &r.app.DecisionReason, &r.app.InternalNote,
|
|
&r.app.CorrelationID, &r.app.CreatedAt, &r.app.UpdatedAt,
|
|
&r.submittedAt, &r.reviewedAt, &r.app.Version,
|
|
}
|
|
}
|
|
|
|
func (r *verificationApplicationRow) application() domain.VerificationApplication {
|
|
app := r.app
|
|
app.TargetType = domain.VerificationTargetType(r.targetType)
|
|
app.Status = domain.VerificationStatus(r.status)
|
|
app.CreatedAt = app.CreatedAt.UTC()
|
|
app.UpdatedAt = app.UpdatedAt.UTC()
|
|
if r.submittedAt != nil {
|
|
app.SubmittedAt = r.submittedAt.UTC()
|
|
}
|
|
if r.reviewedAt != nil {
|
|
app.ReviewedAt = r.reviewedAt.UTC()
|
|
}
|
|
// An empty text[] and a never-filled list must not read back differently
|
|
// between the two backends.
|
|
if len(app.SocialLinks) == 0 {
|
|
app.SocialLinks = nil
|
|
}
|
|
if len(app.PressLinks) == 0 {
|
|
app.PressLinks = nil
|
|
}
|
|
return app
|
|
}
|
|
|
|
func scanVerificationApplication(row pgx.Row) (domain.VerificationApplication, error) {
|
|
var r verificationApplicationRow
|
|
if err := row.Scan(r.dest()...); err != nil {
|
|
return domain.VerificationApplication{}, err
|
|
}
|
|
return r.application(), nil
|
|
}
|
|
|
|
func verificationApplicationByID(ctx context.Context, db sqlcgen.DBTX, applicationID int64) (domain.VerificationApplication, error) {
|
|
app, err := scanVerificationApplication(db.QueryRow(ctx, `
|
|
SELECT `+verificationApplicationColumnList+`
|
|
FROM verification_applications
|
|
WHERE id = $1`, applicationID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("get verification application: %w", err)
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// lockVerificationApplicationTx 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 lockVerificationApplicationTx(ctx context.Context, tx pgx.Tx, applicationID int64) (domain.VerificationApplication, error) {
|
|
app, err := scanVerificationApplication(tx.QueryRow(ctx, `
|
|
SELECT `+verificationApplicationColumnList+`
|
|
FROM verification_applications
|
|
WHERE id = $1
|
|
FOR UPDATE`, applicationID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("lock verification application: %w", err)
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// activeVerificationApplicationTx resolves the application occupying a target.
|
|
// verification_applications_active_target_idx guarantees there is at most one, so
|
|
// no ordering is needed to pick it.
|
|
func activeVerificationApplicationTx(ctx context.Context, db sqlcgen.DBTX, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
|
|
app, err := scanVerificationApplication(db.QueryRow(ctx, `
|
|
SELECT `+verificationApplicationColumnList+`
|
|
FROM verification_applications
|
|
WHERE target_type = $1 AND target_id = $2
|
|
AND status IN ('draft', 'submitted', 'in_review')
|
|
LIMIT 1`, string(target), targetID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("get active verification application: %w", err)
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
func verificationDraftForApplicantTx(ctx context.Context, db sqlcgen.DBTX, applicantUserID int64) (domain.VerificationApplication, error) {
|
|
app, err := scanVerificationApplication(db.QueryRow(ctx, `
|
|
SELECT `+verificationApplicationColumnList+`
|
|
FROM verification_applications
|
|
WHERE applicant_user_id = $1 AND status = 'draft'
|
|
LIMIT 1`, applicantUserID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("get verification draft: %w", err)
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
func newestApprovedVerificationApplicationTx(ctx context.Context, tx pgx.Tx, target domain.VerificationTargetType, targetID int64) (domain.VerificationApplication, error) {
|
|
app, err := scanVerificationApplication(tx.QueryRow(ctx, `
|
|
SELECT `+verificationApplicationColumnList+`
|
|
FROM verification_applications
|
|
WHERE target_type = $1 AND target_id = $2 AND status = 'approved'
|
|
ORDER BY reviewed_at DESC NULLS LAST, id DESC
|
|
LIMIT 1
|
|
FOR UPDATE`, string(target), targetID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.VerificationApplication{}, domain.ErrVerificationApplicationNotFound
|
|
}
|
|
if err != nil {
|
|
return domain.VerificationApplication{}, fmt.Errorf("lock approved verification application: %w", err)
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
type verificationEventInput struct {
|
|
applicationID int64
|
|
kind domain.VerificationApplicationEventKind
|
|
from domain.VerificationStatus
|
|
to domain.VerificationStatus
|
|
actor string
|
|
reason string
|
|
note string
|
|
correlationID string
|
|
createdAt time.Time
|
|
}
|
|
|
|
// insertVerificationEventTx appends one history row. There is deliberately no
|
|
// update or delete counterpart: the timeline is the audit trail.
|
|
func insertVerificationEventTx(ctx context.Context, tx pgx.Tx, in verificationEventInput) error {
|
|
if !in.kind.Valid() {
|
|
return domain.ErrVerificationApplicationInvalid
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO verification_application_events (
|
|
application_id, kind, from_status, to_status, actor, reason, note,
|
|
correlation_id, created_at
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
|
in.applicationID, string(in.kind), string(in.from), string(in.to),
|
|
in.actor, in.reason, in.note, in.correlationID, in.createdAt,
|
|
); err != nil {
|
|
return fmt.Errorf("insert verification application event: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// insertVerificationNotificationTx enqueues the applicant notification. The
|
|
// unique key is the decision, not the attempt, so a retry that reaches this far
|
|
// cannot produce a second message.
|
|
func insertVerificationNotificationTx(ctx context.Context, tx pgx.Tx, applicationID, recipientUserID int64, kind string, now time.Time) error {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO verification_notification_outbox (
|
|
application_id, recipient_user_id, kind, created_at
|
|
) VALUES ($1,$2,$3,$4)
|
|
ON CONFLICT ON CONSTRAINT `+verificationNotificationConstraint+` DO NOTHING`,
|
|
applicationID, recipientUserID, kind, now,
|
|
); err != nil {
|
|
return fmt.Errorf("enqueue verification notification: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func verificationNotificationExistsTx(ctx context.Context, tx pgx.Tx, applicationID int64, kind string) (bool, error) {
|
|
var exists bool
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT true FROM verification_notification_outbox
|
|
WHERE application_id = $1 AND kind = $2`, applicationID, kind).Scan(&exists)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("get verification notification: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
func lockVerificationApplicant(ctx context.Context, tx pgx.Tx, applicantUserID int64) error {
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
|
fmt.Sprintf("verification-applicant:%d", applicantUserID)); err != nil {
|
|
return fmt.Errorf("lock verification applicant: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lockVerificationTarget(ctx context.Context, tx pgx.Tx, target domain.VerificationTargetType, targetID int64) error {
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
|
fmt.Sprintf("verification-target:%s:%d", target, targetID)); err != nil {
|
|
return fmt.Errorf("lock verification target: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateVerificationDraftRequest checks a draft-creation request.
|
|
//
|
|
// It deliberately does not run SubmitVerificationApplicationRequest.Validate: a
|
|
// draft is opened before the bot has collected anything, so only the fields
|
|
// already present must be well formed. The submission bar is enforced by
|
|
// SubmitVerificationApplication.
|
|
func validateVerificationDraftRequest(req domain.SubmitVerificationApplicationRequest) error {
|
|
if req.ApplicantUserID <= 0 || req.TargetID <= 0 || !req.TargetType.Valid() {
|
|
return domain.ErrVerificationApplicationInvalid
|
|
}
|
|
if utf8.RuneCountInString(req.TargetTitle) > domain.MaxVerificationTitleLength {
|
|
return domain.ErrVerificationApplicationInvalid
|
|
}
|
|
if len(req.TargetUsername) > maxVerificationUsernameBytes {
|
|
return domain.ErrVerificationApplicationInvalid
|
|
}
|
|
if len(req.CorrelationID) > domain.MaxVerificationCorrelationLen {
|
|
return domain.ErrVerificationApplicationInvalid
|
|
}
|
|
return req.Draft.ValidateDraft()
|
|
}
|
|
|
|
// verificationDraftOf projects the stored payload back onto the applicant input,
|
|
// so submission is validated against the same domain rules the bot dialog used.
|
|
func verificationDraftOf(app domain.VerificationApplication) domain.VerificationDraftInput {
|
|
return domain.VerificationDraftInput{
|
|
Category: app.Category,
|
|
Description: app.Description,
|
|
OfficialWebsite: app.OfficialWebsite,
|
|
SocialLinks: app.SocialLinks,
|
|
PressLinks: app.PressLinks,
|
|
AdditionalNote: app.AdditionalNote,
|
|
}
|
|
}
|
|
|
|
func normalizeVerificationDecision(decision domain.VerificationDecision) domain.VerificationDecision {
|
|
decision.Reviewer = strings.TrimSpace(decision.Reviewer)
|
|
decision.Reason = strings.TrimSpace(decision.Reason)
|
|
decision.InternalNote = strings.TrimSpace(decision.InternalNote)
|
|
decision.CorrelationID = strings.TrimSpace(decision.CorrelationID)
|
|
return decision
|
|
}
|
|
|
|
// verificationLinksArg keeps a NOT NULL text[] out of NULL: pgx encodes a nil
|
|
// slice as NULL, which the column rejects.
|
|
func verificationLinksArg(links []string) []string {
|
|
if links == nil {
|
|
return []string{}
|
|
}
|
|
return links
|
|
}
|
|
|
|
func verificationLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return defaultVerificationListLimit
|
|
}
|
|
if limit > maxVerificationListLimit {
|
|
return maxVerificationListLimit
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func verificationFilterStatuses(filter domain.VerificationApplicationFilter) ([]string, error) {
|
|
statuses := make([]string, 0, len(filter.Statuses))
|
|
for _, status := range filter.Statuses {
|
|
if !status.Valid() {
|
|
return nil, domain.ErrVerificationApplicationInvalid
|
|
}
|
|
statuses = append(statuses, string(status))
|
|
}
|
|
return statuses, nil
|
|
}
|
|
|
|
// parseVerificationQuery splits the review-queue search term into its two
|
|
// shapes: a number addresses an application id or a peer id, anything else is a
|
|
// username prefix. Telegram usernames never start with a digit, so the two
|
|
// shapes cannot collide.
|
|
func parseVerificationQuery(query string) (numeric int64, isNumeric bool, prefix 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)
|
|
}
|
|
|
|
// truncateVerificationError bounds the outbox error text at the column CHECK,
|
|
// on a rune boundary, because a worker hands over whatever the transport said.
|
|
func truncateVerificationError(reason string) string {
|
|
if len(reason) <= maxVerificationOutboxErrorBytes {
|
|
return reason
|
|
}
|
|
cut := maxVerificationOutboxErrorBytes
|
|
for cut > 0 && !utf8.ValidString(reason[:cut]) {
|
|
cut--
|
|
}
|
|
return reason[:cut]
|
|
}
|