feat: sync account deletion lifecycle

Sync telesrv 73f5c91 (feat(account): implement unified account deletion lifecycle).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-19 20:35:58 +08:00
parent 96a419b565
commit edb7057757
32 changed files with 3236 additions and 88 deletions

View file

@ -436,7 +436,7 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("seed appearance: %w", err) return fmt.Errorf("seed appearance: %w", err)
} else if !stats.Skipped { } else if !stats.Skipped {
logger.Info("外观种子导入完成", logger.Info("外观种子导入完成",
zap.String("source", "default-seed"), zap.String("source", "orange-live"),
zap.Int("wallpapers", stats.Wallpapers), zap.Int("wallpapers", stats.Wallpapers),
zap.Int("documents", stats.Documents), zap.Int("documents", stats.Documents),
zap.Int("blobs", stats.Blobs), zap.Int("blobs", stats.Blobs),
@ -526,6 +526,7 @@ func run(logger *zap.Logger) error {
account.WithBusinessAutomation(passwordStore), account.WithBusinessAutomation(passwordStore),
account.WithUsers(userStore), account.WithUsers(userStore),
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts), account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
account.WithAccountLifecycle(postgres.NewAccountLifecycleStore(pool)),
account.WithPublicBaseURL(cfg.PublicBaseURL), account.WithPublicBaseURL(cfg.PublicBaseURL),
} }
var webhookSender otpdelivery.Sender var webhookSender otpdelivery.Sender
@ -871,6 +872,7 @@ func run(logger *zap.Logger) error {
go router.RunPresenceSweeper(ctx, time.Minute) go router.RunPresenceSweeper(ctx, time.Minute)
go activeSessions.RunPendingSweeper(ctx, time.Minute) go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch) go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
go router.RunAccountLifecycle(ctx, time.Minute, 500)
go func() { go func() {
interval := cfg.StarGiftSweepInterval interval := cfg.StarGiftSweepInterval
if interval <= 0 { if interval <= 0 {

View file

@ -0,0 +1,25 @@
DROP TRIGGER IF EXISTS account_settings_ttl_trigger ON public.account_settings;
DROP FUNCTION IF EXISTS public.telesrv_account_settings_ttl_trigger();
DROP TRIGGER IF EXISTS users_account_delete_at_trigger ON public.users;
DROP FUNCTION IF EXISTS public.telesrv_users_account_delete_at_trigger();
DROP FUNCTION IF EXISTS public.telesrv_account_delete_at(timestamp with time zone, bigint, integer);
DROP TRIGGER IF EXISTS account_passwords_changed_at_trigger ON public.account_passwords;
DROP FUNCTION IF EXISTS public.telesrv_password_changed_at_trigger();
ALTER TABLE public.account_passwords DROP COLUMN IF EXISTS password_changed_at;
ALTER TABLE public.account_settings
DROP CONSTRAINT account_settings_account_ttl_days_check,
ADD CONSTRAINT account_settings_account_ttl_days_check CHECK (account_ttl_days > 0);
DROP TABLE IF EXISTS public.account_deletion_notifications;
DROP TABLE IF EXISTS public.account_deletion_requests;
DROP INDEX IF EXISTS public.dialogs_user_peer_reverse_idx;
DROP INDEX IF EXISTS public.users_account_delete_due_idx;
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check;
ALTER TABLE public.users
DROP COLUMN IF EXISTS account_delete_at,
DROP COLUMN IF EXISTS deletion_reason,
DROP COLUMN IF EXISTS deletion_source,
DROP COLUMN IF EXISTS deleted_at;

View file

@ -0,0 +1,216 @@
-- Unified account deletion lifecycle. A deleted account remains as a minimal
-- user tombstone so historical messages keep a stable sender id, while all
-- reusable identity and profile fields are released atomically.
ALTER TABLE public.users
ADD COLUMN deleted_at timestamp with time zone,
ADD COLUMN deletion_source text DEFAULT '' NOT NULL,
ADD COLUMN deletion_reason text DEFAULT '' NOT NULL,
ADD COLUMN account_delete_at timestamp with time zone;
ALTER TABLE public.account_passwords
ADD COLUMN password_changed_at timestamp with time zone;
ALTER TABLE public.account_settings
DROP CONSTRAINT account_settings_account_ttl_days_check,
ADD CONSTRAINT account_settings_account_ttl_days_check
CHECK (account_ttl_days BETWEEN 1 AND 3650);
CREATE OR REPLACE FUNCTION public.telesrv_password_changed_at_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
ELSIF NEW.has_password IS DISTINCT FROM OLD.has_password
OR NEW.srp_verifier IS DISTINCT FROM OLD.srp_verifier
OR NEW.current_algo_salt1 IS DISTINCT FROM OLD.current_algo_salt1
OR NEW.current_algo_salt2 IS DISTINCT FROM OLD.current_algo_salt2
OR NEW.current_algo_g IS DISTINCT FROM OLD.current_algo_g
OR NEW.current_algo_p IS DISTINCT FROM OLD.current_algo_p THEN
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
ELSE
NEW.password_changed_at := OLD.password_changed_at;
END IF;
RETURN NEW;
END
$$;
UPDATE public.account_passwords
SET password_changed_at = updated_at
WHERE has_password = true AND password_changed_at IS NULL;
CREATE TRIGGER account_passwords_changed_at_trigger
BEFORE INSERT OR UPDATE ON public.account_passwords
FOR EACH ROW EXECUTE FUNCTION public.telesrv_password_changed_at_trigger();
ALTER TABLE public.users
ADD CONSTRAINT users_deletion_state_check CHECK (
(deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '')
OR
(deleted_at IS NOT NULL
AND deletion_source IN (
'manual',
'forgot_password',
'tos_decline',
'password_reset_expiry',
'account_ttl',
'freeze_expiry'
)
AND account_delete_at IS NULL
AND phone = ''
AND first_name = ''
AND last_name = ''
AND username = ''
AND country_code = ''
AND about = ''
AND verified = false
AND support = false
AND premium_expires_at IS NULL
AND emoji_status_document_id = 0
AND emoji_status_until = 0
AND color_set = false
AND color = 0
AND color_background_emoji_id = 0
AND profile_color_set = false
AND profile_color = 0
AND profile_color_background_emoji_id = 0
AND birthday_day = 0
AND birthday_month = 0
AND birthday_year = 0
AND personal_channel_id = 0
AND last_seen_at = 0
AND octet_length(deletion_reason) <= 1024)
);
CREATE INDEX users_account_delete_due_idx
ON public.users (account_delete_at, id)
WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at IS NOT NULL;
CREATE TABLE public.account_deletion_requests (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES public.users(id),
requester_auth_key_id bigint NOT NULL,
state text DEFAULT 'pending' NOT NULL,
reason text DEFAULT '' NOT NULL,
confirm_hash_digest bytea NOT NULL,
requested_at timestamp with time zone DEFAULT now() NOT NULL,
execute_at timestamp with time zone NOT NULL,
completed_at timestamp with time zone,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_deletion_requests_state_check CHECK (
(state = 'pending' AND completed_at IS NULL)
OR (state IN ('cancelled', 'executed') AND completed_at IS NOT NULL)
),
CONSTRAINT account_deletion_requests_reason_check CHECK (octet_length(reason) <= 1024),
CONSTRAINT account_deletion_requests_hash_check CHECK (octet_length(confirm_hash_digest) = 32)
);
CREATE UNIQUE INDEX account_deletion_requests_one_pending_user_idx
ON public.account_deletion_requests(user_id) WHERE state = 'pending';
CREATE UNIQUE INDEX account_deletion_requests_pending_hash_idx
ON public.account_deletion_requests(confirm_hash_digest) WHERE state = 'pending';
CREATE INDEX account_deletion_requests_due_idx
ON public.account_deletion_requests(execute_at, id) WHERE state = 'pending';
-- updateUser is not a pts-bearing update. Keep a dedicated durable online-nudge
-- queue so a crash between tombstone commit and best-effort fan-out is recovered.
-- Offline clients converge from authoritative dialog/history hydration instead
-- of an immortal retry queue or invented user pts events.
CREATE TABLE public.account_deletion_notifications (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
target_user_id bigint NOT NULL REFERENCES public.users(id),
deleted_user_id bigint NOT NULL REFERENCES public.users(id),
status text DEFAULT 'pending' NOT NULL,
attempts integer DEFAULT 0 NOT NULL,
next_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
lease_until timestamp with time zone,
last_error text DEFAULT '' NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_deletion_notifications_status_check
CHECK (status IN ('pending', 'dispatching', 'delivered')),
CONSTRAINT account_deletion_notifications_attempts_check CHECK (attempts >= 0),
CONSTRAINT account_deletion_notifications_not_self_check CHECK (target_user_id <> deleted_user_id),
UNIQUE (target_user_id, deleted_user_id)
);
CREATE INDEX account_deletion_notifications_ready_idx
ON public.account_deletion_notifications(next_attempt_at, id)
WHERE status = 'pending';
-- Account deletion discovers peers that have an inbound dialog row pointing at
-- the deleted user. The ordinary dialog PK only covers the owner direction;
-- keep the reverse lookup indexed so tombstoning one account never scans every
-- user's dialog table.
CREATE INDEX dialogs_user_peer_reverse_idx
ON public.dialogs(peer_id, user_id)
WHERE peer_type = 'user';
CREATE OR REPLACE FUNCTION public.telesrv_account_delete_at(
p_created_at timestamp with time zone,
p_last_seen_at bigint,
p_ttl_days integer
) RETURNS timestamp with time zone
LANGUAGE sql IMMUTABLE AS $$
SELECT GREATEST(
p_created_at,
CASE WHEN p_last_seen_at > 0 THEN to_timestamp(p_last_seen_at) ELSE p_created_at END
) + make_interval(days => p_ttl_days)
$$;
CREATE OR REPLACE FUNCTION public.telesrv_users_account_delete_at_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_ttl_days integer;
BEGIN
IF NEW.deleted_at IS NOT NULL OR NEW.is_bot THEN
NEW.account_delete_at := NULL;
RETURN NEW;
END IF;
SELECT account_ttl_days INTO v_ttl_days
FROM public.account_settings WHERE user_id = NEW.id;
NEW.account_delete_at := public.telesrv_account_delete_at(
NEW.created_at,
NEW.last_seen_at,
COALESCE(v_ttl_days, 365)
);
RETURN NEW;
END
$$;
CREATE TRIGGER users_account_delete_at_trigger
BEFORE INSERT OR UPDATE OF last_seen_at, deleted_at, is_bot
ON public.users
FOR EACH ROW EXECUTE FUNCTION public.telesrv_users_account_delete_at_trigger();
CREATE OR REPLACE FUNCTION public.telesrv_account_settings_ttl_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_user_id bigint;
v_ttl_days integer;
BEGIN
v_user_id := COALESCE(NEW.user_id, OLD.user_id);
v_ttl_days := CASE WHEN TG_OP = 'DELETE' THEN 365 ELSE NEW.account_ttl_days END;
UPDATE public.users
SET account_delete_at = public.telesrv_account_delete_at(created_at, last_seen_at, v_ttl_days),
updated_at = now()
WHERE id = v_user_id AND deleted_at IS NULL AND is_bot = false;
RETURN COALESCE(NEW, OLD);
END
$$;
CREATE TRIGGER account_settings_ttl_trigger
AFTER INSERT OR UPDATE OF account_ttl_days OR DELETE
ON public.account_settings
FOR EACH ROW EXECUTE FUNCTION public.telesrv_account_settings_ttl_trigger();
UPDATE public.users u
SET account_delete_at = public.telesrv_account_delete_at(
u.created_at,
u.last_seen_at,
COALESCE((
SELECT s.account_ttl_days
FROM public.account_settings s
WHERE s.user_id = u.id
), 365)
)
WHERE u.deleted_at IS NULL AND u.is_bot = false;

View file

@ -0,0 +1,360 @@
package account
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/otpdelivery"
"telesrv/internal/store"
)
const accountDeletionDelay = 7 * 24 * time.Hour
// DeleteAccount implements the official 2FA deletion decision. A supplied and
// valid SRP proof always deletes immediately. Without a proof, an account whose
// password is older than seven days and which was active during the last seven
// days gets a cancellable seven-day window; all other cases delete immediately.
func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error) {
if s == nil || s.lifecycle == nil || userID == 0 || authKeyID == ([8]byte{}) {
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
}
if now.IsZero() {
now = time.Now().UTC()
}
reason = strings.TrimSpace(reason)
if len(reason) > 1024 {
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
}
snapshot, found, err := s.lifecycle.AccountDeletionSnapshot(ctx, userID)
if err != nil {
return domain.AccountDeleteOutcome{}, err
}
if !found {
return domain.AccountDeleteOutcome{}, domain.ErrUserNotFound
}
if snapshot.User.Deleted {
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: domain.AccountDeletionResult{User: snapshot.User}}, nil
}
if snapshot.User.Bot || domain.IsSystemUserID(snapshot.User.ID) {
return domain.AccountDeleteOutcome{}, domain.ErrAccountDeletionForbidden
}
if password != nil && !password.Empty {
if !snapshot.HasPassword {
return domain.AccountDeleteOutcome{}, domain.ErrPasswordHashInvalid
}
if err := s.CheckPassword(ctx, userID, *password); err != nil {
return domain.AccountDeleteOutcome{}, err
}
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
}
if !snapshot.HasPassword {
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
}
lastActive := snapshot.User.CreatedAt
if snapshot.User.LastSeenAt > 0 {
seen := time.Unix(int64(snapshot.User.LastSeenAt), 0).UTC()
if seen.After(lastActive) {
lastActive = seen
}
}
passwordOldEnough := !snapshot.PasswordUpdatedAt.IsZero() && !snapshot.PasswordUpdatedAt.After(now.Add(-accountDeletionDelay))
recentlyActive := !lastActive.IsZero() && !lastActive.Before(now.Add(-accountDeletionDelay))
if !passwordOldEnough || !recentlyActive {
return s.executeAccountDeletion(ctx, userID, deletionSourceForReason(reason), reason, now)
}
if snapshot.Pending != nil {
return delayedDeleteOutcome(*snapshot.Pending, now), nil
}
rawToken, digest, err := newAccountDeletionToken()
if err != nil {
return domain.AccountDeleteOutcome{}, err
}
executeAt := now.Add(accountDeletionDelay)
message := fmt.Sprintf(
"A request was made to delete your Telegram account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
)
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
UserID: userID,
RequesterAuthKeyID: authKeyID,
Reason: reason,
ConfirmHashDigest: digest,
ServiceMessage: message,
RequestedAt: now,
ExecuteAt: executeAt,
})
if err != nil {
return domain.AccountDeleteOutcome{}, err
}
return delayedDeleteOutcome(pending, now), nil
}
func (s *Service) executeAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeleteOutcome, error) {
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, userID, source, reason, now)
if err != nil {
return domain.AccountDeleteOutcome{}, err
}
if s.userCache != nil {
_ = s.userCache.Delete(ctx, []int64{userID})
}
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteImmediate, Deletion: result}, nil
}
func delayedDeleteOutcome(pending domain.AccountDeletionRequest, now time.Time) domain.AccountDeleteOutcome {
wait := int(time.Until(pending.ExecuteAt).Seconds())
if !now.IsZero() {
wait = int(pending.ExecuteAt.Sub(now).Seconds())
}
if wait < 0 {
wait = 0
}
return domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: wait, ExecuteAt: pending.ExecuteAt}
}
func deletionSourceForReason(reason string) domain.AccountDeletionSource {
switch strings.ToLower(strings.TrimSpace(reason)) {
case "forgot password":
return domain.AccountDeletionForgotPassword
case "decline tos update":
return domain.AccountDeletionTOSDecline
default:
return domain.AccountDeletionManual
}
}
func newAccountDeletionToken() (string, [32]byte, error) {
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", [32]byte{}, fmt.Errorf("generate account deletion token: %w", err)
}
token := hex.EncodeToString(raw[:])
return token, sha256.Sum256([]byte(token)), nil
}
func accountDeletionDigest(raw string) ([32]byte, error) {
raw = strings.TrimSpace(raw)
decoded, err := hex.DecodeString(raw)
if err != nil || len(decoded) != 32 {
return [32]byte{}, domain.ErrAccountDeletionHashInvalid
}
return sha256.Sum256([]byte(raw)), nil
}
// SendConfirmPhoneCode validates the secret confirmphone link and issues an
// auth-key-scoped SMS code to the account's current phone.
func (s *Service) SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, rawHash string) (string, domain.AuthCodeDelivery, error) {
digest, err := accountDeletionDigest(rawHash)
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
if s == nil || s.lifecycle == nil || s.users == nil || s.codes == nil || userID == 0 || authKeyID == ([8]byte{}) {
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
}
if _, found, err := s.lifecycle.PendingAccountDeletionByHash(ctx, userID, digest); err != nil {
return "", domain.AuthCodeDelivery{}, err
} else if !found {
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
if !found || u.Deleted || u.Phone == "" {
return "", domain.AuthCodeDelivery{}, domain.ErrAccountDeletionHashInvalid
}
return s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
}
func (s *Service) issueConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string, digest [32]byte) (string, domain.AuthCodeDelivery, error) {
hash, err := phoneChangeHash()
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
code := s.phoneChangeCode
channel := store.PhoneCodeChannelPhone
deliveryID := ""
if s.phoneCodeSender != nil {
code, err = randomDigits(s.phoneCodeLength)
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
deliveryID, err = otpdelivery.NewDeliveryID()
if err != nil {
return "", domain.AuthCodeDelivery{}, err
}
channel = store.PhoneCodeChannelSMS
}
if strings.TrimSpace(code) == "" {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("confirm phone code service is not configured")
}
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: code,
DeliveryID: deliveryID,
Channel: channel,
Purpose: store.PhoneCodePurposeConfirmPhone,
UserID: userID,
AuthKeyID: authKeyID,
SessionID: sessionID,
MaxAttempts: s.phoneChangeMaxAttempts,
AccountDeletionHash: hex.EncodeToString(digest[:]),
}
expiresAt := time.Now().Add(s.phoneChangeCodeTTL)
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store confirm phone code: %w", err)
}
if s.phoneCodeSender != nil {
if err := deliverOTP(ctx, s.phoneCodeSender, otpdelivery.Request{
DeliveryID: deliveryID,
Purpose: otpdelivery.PurposeConfirmPhone,
Channel: otpdelivery.ChannelSMS,
Recipient: phone,
Code: code,
ExpiresAt: expiresAt,
}); err != nil {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
if _, _, cleanupErr := s.codes.ConsumeScoped(cleanupCtx, hash, rec.Scope()); cleanupErr != nil {
return "", domain.AuthCodeDelivery{}, errors.Join(err, cleanupErr)
}
return "", domain.AuthCodeDelivery{}, err
}
}
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(code)}, nil
}
// ConfirmPhone consumes the scoped OTP, cancels the pending deletion and
// revokes the auth key that initiated the deletion attempt.
func (s *Service) ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error) {
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
return nil, domain.ErrPhoneCodeEmpty
}
if s == nil || s.codes == nil || s.lifecycle == nil || s.users == nil {
return nil, domain.ErrPhoneCodeInvalid
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil {
return nil, err
}
if !found || u.Deleted || u.Phone == "" {
return nil, domain.ErrPhoneCodeInvalid
}
scope := store.PhoneCodeScope{Purpose: store.PhoneCodePurposeConfirmPhone, UserID: userID, AuthKeyID: authKeyID, Phone: u.Phone}
verified, err := s.codes.VerifyScoped(ctx, phoneCodeHash, scope, strings.TrimSpace(code), s.phoneChangeMaxAttempts)
if err != nil {
return nil, err
}
switch verified.Status {
case store.LoginCodeVerifyMissing:
return nil, domain.ErrPhoneCodeExpired
case store.LoginCodeVerifyInvalid:
return nil, domain.ErrPhoneCodeInvalid
case store.LoginCodeVerifyAccepted:
default:
return nil, domain.ErrPhoneCodeInvalid
}
digestBytes, err := hex.DecodeString(verified.Record.AccountDeletionHash)
if err != nil || len(digestBytes) != 32 {
return nil, domain.ErrPhoneCodeInvalid
}
var digest [32]byte
copy(digest[:], digestBytes)
if now.IsZero() {
now = time.Now().UTC()
}
return s.lifecycle.CancelAccountDeletion(ctx, userID, digest, now)
}
// ResendConfirmPhoneCode handles auth.resendCode only when the supplied hash is
// an active confirm-phone code for this authorized user/auth key.
func (s *Service) ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error) {
if s == nil || s.codes == nil || s.users == nil || userID == 0 {
return "", domain.AuthCodeDelivery{}, false, nil
}
rec, found, err := s.codes.Get(ctx, oldHash)
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
return "", domain.AuthCodeDelivery{}, false, err
}
u, found, err := s.users.ByID(ctx, userID)
if err != nil || !found || u.Deleted || domain.NormalizePhone(phone) != domain.NormalizePhone(u.Phone) {
if err == nil {
err = domain.ErrPhoneCodeInvalid
}
return "", domain.AuthCodeDelivery{}, true, err
}
consumed, found, err := s.codes.ConsumeScoped(ctx, oldHash, rec.Scope())
if err != nil || !found {
if err == nil {
err = domain.ErrPhoneCodeExpired
}
return "", domain.AuthCodeDelivery{}, true, err
}
digestBytes, err := hex.DecodeString(consumed.AccountDeletionHash)
if err != nil || len(digestBytes) != 32 {
return "", domain.AuthCodeDelivery{}, true, domain.ErrPhoneCodeInvalid
}
var digest [32]byte
copy(digest[:], digestBytes)
hash, delivery, err := s.issueConfirmPhoneCode(ctx, userID, authKeyID, sessionID, u.Phone, digest)
return hash, delivery, true, err
}
func (s *Service) CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error) {
if s == nil || s.codes == nil || userID == 0 {
return false, nil
}
rec, found, err := s.codes.Get(ctx, hash)
if err != nil || !found || rec.Purpose != store.PhoneCodePurposeConfirmPhone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
return false, err
}
if domain.NormalizePhone(phone) != domain.NormalizePhone(rec.Phone) {
return true, domain.ErrPhoneCodeInvalid
}
_, _, err = s.codes.ConsumeScoped(ctx, hash, rec.Scope())
return true, err
}
func (s *Service) SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error) {
if s == nil || s.lifecycle == nil || limit <= 0 {
return nil, nil
}
candidates, err := s.lifecycle.DueAccountDeletions(ctx, now, limit)
if err != nil {
return nil, err
}
out := make([]domain.AccountDeletionResult, 0, len(candidates))
for _, candidate := range candidates {
result, err := s.lifecycle.ExecuteAccountDeletion(ctx, candidate.UserID, candidate.Source, "", now)
if err != nil {
return out, err
}
if s.userCache != nil {
_ = s.userCache.Delete(ctx, []int64{candidate.UserID})
}
out = append(out, result)
}
return out, nil
}
func (s *Service) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
if s == nil || s.lifecycle == nil {
return nil, nil
}
return s.lifecycle.ClaimAccountDeletionNotifications(ctx, now, limit, lease)
}
func (s *Service) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
if s == nil || s.lifecycle == nil {
return nil
}
return s.lifecycle.CompleteAccountDeletionNotification(ctx, id, now)
}

View file

@ -0,0 +1,149 @@
package account
import (
"context"
"errors"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestDeleteAccountTwoFADelayDecisionMatrix(t *testing.T) {
now := time.Unix(1_800_000_000, 0).UTC()
authKey := [8]byte{1}
tests := []struct {
name string
hasPassword bool
passwordUpdated time.Time
createdAt time.Time
lastSeen int
wantKind domain.AccountDeleteKind
}{
{name: "no password deletes immediately", createdAt: now.Add(-time.Hour), lastSeen: int(now.Unix()), wantKind: domain.AccountDeleteImmediate},
{name: "old password and recent activity delays", hasPassword: true, passwordUpdated: now.Add(-8 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteDelayed},
{name: "recent password change deletes immediately", hasPassword: true, passwordUpdated: now.Add(-2 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
{name: "inactive account deletes immediately", hasPassword: true, passwordUpdated: now.Add(-30 * 24 * time.Hour), createdAt: now.Add(-30 * 24 * time.Hour), lastSeen: int(now.Add(-8 * 24 * time.Hour).Unix()), wantKind: domain.AccountDeleteImmediate},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{
User: domain.User{ID: 42, Phone: "15550010000", CreatedAt: test.createdAt, LastSeenAt: test.lastSeen},
HasPassword: test.hasPassword, PasswordUpdatedAt: test.passwordUpdated,
}}
svc := NewService(memory.NewPasswordStore(), WithAccountLifecycle(lifecycle))
outcome, err := svc.DeleteAccount(context.Background(), 42, authKey, "manual", nil, now)
if err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
if outcome.Kind != test.wantKind {
t.Fatalf("kind = %q, want %q", outcome.Kind, test.wantKind)
}
if test.wantKind == domain.AccountDeleteDelayed {
if lifecycle.scheduled == nil || !strings.Contains(lifecycle.scheduled.ServiceMessage, "tg://confirmphone?") || outcome.WaitSeconds != int(accountDeletionDelay.Seconds()) {
t.Fatalf("delayed outcome=%+v scheduled=%+v", outcome, lifecycle.scheduled)
}
} else if lifecycle.executedSource == "" {
t.Fatal("immediate path did not execute the tombstone boundary")
}
})
}
}
func TestConfirmPhoneCancelsPendingDeletionAndRevokesRequester(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_800_000_000, 0).UTC()
users := memory.NewUserStore()
u, err := users.Create(ctx, domain.User{Phone: "15550010001", FirstName: "Alice"})
if err != nil {
t.Fatal(err)
}
requester := [8]byte{9}
confirming := [8]byte{8}
lifecycle := &fakeAccountLifecycleStore{snapshot: domain.AccountDeletionSnapshot{User: u}}
svc := NewService(memory.NewPasswordStore(),
WithUsers(users),
WithPhoneChange(nil, nil, memory.NewCodeStore(), nil, "12345", 5*time.Minute, 5),
WithAccountLifecycle(lifecycle),
)
rawToken, digest, err := newAccountDeletionToken()
if err != nil {
t.Fatal(err)
}
lifecycle.pending = &domain.AccountDeletionRequest{
ID: 1, UserID: u.ID, RequesterAuthKeyID: requester, State: domain.AccountDeletionPending,
ConfirmHashDigest: digest, RequestedAt: now, ExecuteAt: now.Add(accountDeletionDelay),
}
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, u.ID, confirming, 77, rawToken)
if err != nil || hash == "" || delivery.Length != 5 {
t.Fatalf("SendConfirmPhoneCode hash=%q delivery=%+v err=%v", hash, delivery, err)
}
revoked, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(time.Minute))
if err != nil {
t.Fatalf("ConfirmPhone: %v", err)
}
if len(revoked) != 1 || revoked[0].AuthKeyID != requester || lifecycle.pending != nil {
t.Fatalf("revoked=%+v pending=%+v", revoked, lifecycle.pending)
}
if _, err := svc.ConfirmPhone(ctx, u.ID, confirming, hash, "12345", now.Add(2*time.Minute)); !errors.Is(err, domain.ErrPhoneCodeExpired) {
t.Fatalf("replay error = %v, want expired", err)
}
}
type fakeAccountLifecycleStore struct {
snapshot domain.AccountDeletionSnapshot
pending *domain.AccountDeletionRequest
scheduled *domain.ScheduleAccountDeletion
executedSource domain.AccountDeletionSource
}
func (f *fakeAccountLifecycleStore) AccountDeletionSnapshot(context.Context, int64) (domain.AccountDeletionSnapshot, bool, error) {
f.snapshot.Pending = f.pending
return f.snapshot, true, nil
}
func (f *fakeAccountLifecycleStore) ScheduleAccountDeletion(_ context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
f.scheduled = &req
pending := domain.AccountDeletionRequest{ID: 1, UserID: req.UserID, RequesterAuthKeyID: req.RequesterAuthKeyID, State: domain.AccountDeletionPending, Reason: req.Reason, ConfirmHashDigest: req.ConfirmHashDigest, RequestedAt: req.RequestedAt, ExecuteAt: req.ExecuteAt}
f.pending = &pending
return pending, true, nil
}
func (f *fakeAccountLifecycleStore) PendingAccountDeletionByHash(_ context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
return domain.AccountDeletionRequest{}, false, nil
}
return *f.pending, true, nil
}
func (f *fakeAccountLifecycleStore) ExecuteAccountDeletion(_ context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
f.executedSource = source
u := f.snapshot.User
u.Deleted = true
u.DeletedAt = now.Unix()
u.DeletionSource = source
u.DeletionReason = reason
u = u.DeletedTombstone()
return domain.AccountDeletionResult{User: u, Changed: true}, nil
}
func (f *fakeAccountLifecycleStore) CancelAccountDeletion(_ context.Context, userID int64, digest [32]byte, _ time.Time) ([]domain.Authorization, error) {
if f.pending == nil || f.pending.UserID != userID || f.pending.ConfirmHashDigest != digest {
return nil, domain.ErrAccountDeletionHashInvalid
}
revoked := []domain.Authorization{{AuthKeyID: f.pending.RequesterAuthKeyID, UserID: userID}}
f.pending = nil
return revoked, nil
}
func (*fakeAccountLifecycleStore) DueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionCandidate, error) {
return nil, nil
}
func (*fakeAccountLifecycleStore) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
return nil, nil
}
func (*fakeAccountLifecycleStore) CompleteAccountDeletionNotification(context.Context, int64, time.Time) error {
return nil
}

View file

@ -43,6 +43,7 @@ type Service struct {
userCache store.UserCache userCache store.UserCache
authorizations store.AuthorizationStore authorizations store.AuthorizationStore
phoneChanges store.PhoneChangeStore phoneChanges store.PhoneChangeStore
lifecycle store.AccountLifecycleStore
publicBaseURL string publicBaseURL string
codes store.CodeStore codes store.CodeStore
phoneChangeCode string phoneChangeCode string
@ -166,6 +167,14 @@ func WithPhoneCodeDelivery(sender otpdelivery.Sender, length int) ServiceOption
} }
} }
// WithAccountLifecycle installs the single durable account deletion boundary.
// It shares the already configured phone-code delivery and user cache.
func WithAccountLifecycle(lifecycle store.AccountLifecycleStore) ServiceOption {
return func(s *Service) {
s.lifecycle = lifecycle
}
}
// NewService 创建 account 服务。 // NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{ s := &Service{

View file

@ -0,0 +1,19 @@
package userprojection
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestDeletedUserProjectionCannotReintroducePII(t *testing.T) {
in := domain.User{ID: 42, AccessHash: 99, Deleted: true, Phone: "stale", FirstName: "Stale", PhotoID: 123, Contact: true}
got, err := New().One(context.Background(), 7, in)
if err != nil {
t.Fatal(err)
}
if !got.Deleted || got.ID != 42 || got.Phone != "" || got.FirstName != "" || got.PhotoID != 0 || got.Contact {
t.Fatalf("deleted projection leaked PII: %+v", got)
}
}

View file

@ -83,6 +83,7 @@ func New(opts ...Option) *Projector {
// ForViewer applies both current profile photos and owner-specific contact view. // ForViewer applies both current profile photos and owner-specific contact view.
func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) { func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
users = sanitizeDeletedUsers(users)
if p == nil { if p == nil {
return users, nil return users, nil
} }
@ -112,6 +113,7 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use
// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。 // (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。
// 调用方传入的 users 不被修改(内部复制)。 // 调用方传入的 users 不被修改(内部复制)。
func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) { func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) {
users = sanitizeDeletedUsers(users)
out := make(map[int64][]domain.User, len(viewerUserIDs)) out := make(map[int64][]domain.User, len(viewerUserIDs))
if p == nil || len(users) == 0 { if p == nil || len(users) == 0 {
for _, v := range viewerUserIDs { for _, v := range viewerUserIDs {
@ -170,6 +172,10 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
if u.ID == 0 { if u.ID == 0 {
continue continue
} }
if u.Deleted {
projected[i] = u.DeletedTombstone()
continue
}
if pj, ok := cache[u.ID]; ok { if pj, ok := cache[u.ID]; ok {
projected[i] = pj projected[i] = pj
continue continue
@ -276,13 +282,14 @@ func dedupNonZeroInt64(ids []int64) []int64 {
// WithProfilePhotos enriches users with their current avatar from profile photo storage. // WithProfilePhotos enriches users with their current avatar from profile photo storage.
// The lookup is best-effort: a storage error keeps the original user list. // The lookup is best-effort: a storage error keeps the original user list.
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User { func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
users = sanitizeDeletedUsers(users)
if photos == nil || len(users) == 0 { if photos == nil || len(users) == 0 {
return users return users
} }
ids := make([]int64, 0, len(users)) ids := make([]int64, 0, len(users))
seen := make(map[int64]struct{}, len(users)) seen := make(map[int64]struct{}, len(users))
for _, u := range users { for _, u := range users {
if u.ID == 0 { if u.ID == 0 || u.Deleted {
continue continue
} }
if _, ok := seen[u.ID]; ok { if _, ok := seen[u.ID]; ok {
@ -312,6 +319,7 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [
// In particular, phone is visible for self and contacts; non-contacts should not // In particular, phone is visible for self and contacts; non-contacts should not
// receive a phone field because TDesktop will prefer it over the public name. // receive a phone field because TDesktop will prefer it over the public name.
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) { func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
users = sanitizeDeletedUsers(users)
if contacts == nil || viewerUserID == 0 || len(users) == 0 { if contacts == nil || viewerUserID == 0 || len(users) == 0 {
return users, nil return users, nil
} }
@ -320,7 +328,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in
cache := make(map[int64]domain.User, len(users)) cache := make(map[int64]domain.User, len(users))
for i := range out { for i := range out {
u := out[i] u := out[i]
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot { if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
continue continue
} }
if projected, ok := cache[u.ID]; ok { if projected, ok := cache[u.ID]; ok {
@ -352,6 +360,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
} }
out := make([]domain.User, len(users)) out := make([]domain.User, len(users))
copy(out, users) copy(out, users)
out = sanitizeDeletedUsers(out)
ids := uniqueUserIDs(out) ids := uniqueUserIDs(out)
var ( var (
profileRefs = map[int64]domain.ProfilePhotoRef{} profileRefs = map[int64]domain.ProfilePhotoRef{}
@ -430,6 +439,10 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
if u.ID == 0 { if u.ID == 0 {
continue continue
} }
if u.Deleted {
out[i] = u.DeletedTombstone()
continue
}
if projected, ok := cache[u.ID]; ok { if projected, ok := cache[u.ID]; ok {
out[i] = projected out[i] = projected
continue continue
@ -463,7 +476,7 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
ids := make([]int64, 0, len(users)) ids := make([]int64, 0, len(users))
seen := make(map[int64]struct{}, len(users)) seen := make(map[int64]struct{}, len(users))
for _, u := range users { for _, u := range users {
if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot { if u.ID == 0 || u.Deleted || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot {
continue continue
} }
if _, ok := seen[u.ID]; ok { if _, ok := seen[u.ID]; ok {
@ -479,6 +492,9 @@ func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, vi
} }
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) { func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
if user.Deleted {
return user.DeletedTombstone(), nil
}
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID) contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
if err != nil { if err != nil {
return domain.User{}, err return domain.User{}, err
@ -513,7 +529,7 @@ func uniqueUserIDs(users []domain.User) []int64 {
seen := make(map[int64]struct{}, len(users)) seen := make(map[int64]struct{}, len(users))
ids := make([]int64, 0, len(users)) ids := make([]int64, 0, len(users))
for _, user := range users { for _, user := range users {
if user.ID == 0 { if user.ID == 0 || user.Deleted {
continue continue
} }
if _, ok := seen[user.ID]; ok { if _, ok := seen[user.ID]; ok {
@ -526,6 +542,9 @@ func uniqueUserIDs(users []domain.User) []int64 {
} }
func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User { func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User {
if user.Deleted {
return user.DeletedTombstone()
}
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) { if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) {
return user return user
} }
@ -548,6 +567,9 @@ func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs m
} }
func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User { func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User {
if user.Deleted {
return user.DeletedTombstone()
}
if !found { if !found {
user.Phone = "" user.Phone = ""
user.Contact = false user.Contact = false
@ -574,6 +596,9 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
} }
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) { func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
if user.Deleted {
return user.DeletedTombstone(), nil
}
if privacy == nil { if privacy == nil {
return user, nil return user, nil
} }
@ -647,3 +672,21 @@ func clearPhoto(user *domain.User) {
user.PhotoPersonal = false user.PhotoPersonal = false
user.PhotoHasVideo = false user.PhotoHasVideo = false
} }
func sanitizeDeletedUsers(users []domain.User) []domain.User {
var out []domain.User
for i, user := range users {
if !user.Deleted {
continue
}
if out == nil {
out = make([]domain.User, len(users))
copy(out, users)
}
out[i] = user.DeletedTombstone()
}
if out != nil {
return out
}
return users
}

View file

@ -174,7 +174,12 @@ func DefaultAccountReactionSettings() AccountReactionSettings {
} }
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。 // DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
const DefaultAccountTTLDays = 365 const (
DefaultAccountTTLDays = 365
// MaxAccountTTLDays prevents an untrusted int32 TL value from producing an
// out-of-range PostgreSQL interval/timestamp during deadline maintenance.
MaxAccountTTLDays = 3650
)
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。 // GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。 // DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
@ -212,7 +217,7 @@ func DefaultAccountSettings() AccountSettings {
// NormalizedTTLDays 返回钳制后的账号自毁期限0/越界回落默认)。 // NormalizedTTLDays 返回钳制后的账号自毁期限0/越界回落默认)。
func (s AccountSettings) NormalizedTTLDays() int { func (s AccountSettings) NormalizedTTLDays() int {
if s.AccountTTLDays <= 0 { if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays {
return DefaultAccountTTLDays return DefaultAccountTTLDays
} }
return s.AccountTTLDays return s.AccountTTLDays

View file

@ -0,0 +1,99 @@
package domain
import (
"errors"
"time"
)
var (
ErrAccountDeleted = errors.New("account deleted")
ErrAccountDeletionForbidden = errors.New("account deletion forbidden")
ErrAccountDeletionHashInvalid = errors.New("account deletion hash invalid")
ErrAccountDeletionNotPending = errors.New("account deletion not pending")
)
// AccountDeletionSource is the single audited reason attached to a user
// tombstone. Different entry points share one execution and cleanup path.
type AccountDeletionSource string
const (
AccountDeletionManual AccountDeletionSource = "manual"
AccountDeletionForgotPassword AccountDeletionSource = "forgot_password"
AccountDeletionTOSDecline AccountDeletionSource = "tos_decline"
AccountDeletionPasswordResetExpiry AccountDeletionSource = "password_reset_expiry"
AccountDeletionAccountTTL AccountDeletionSource = "account_ttl"
AccountDeletionFreezeExpiry AccountDeletionSource = "freeze_expiry"
)
type AccountDeletionRequestState string
const (
AccountDeletionPending AccountDeletionRequestState = "pending"
AccountDeletionCancelled AccountDeletionRequestState = "cancelled"
AccountDeletionExecuted AccountDeletionRequestState = "executed"
)
// AccountDeletionRequest represents the seven-day 2FA confirmation window.
// ConfirmHashDigest is SHA-256(raw link token); the raw token is only included
// in the durable service message and is never persisted as a credential.
type AccountDeletionRequest struct {
ID int64
UserID int64
RequesterAuthKeyID [8]byte
State AccountDeletionRequestState
Reason string
ConfirmHashDigest [32]byte
RequestedAt time.Time
ExecuteAt time.Time
CompletedAt time.Time
}
type AccountDeletionSnapshot struct {
User User
HasPassword bool
PasswordUpdatedAt time.Time
Pending *AccountDeletionRequest
}
type ScheduleAccountDeletion struct {
UserID int64
RequesterAuthKeyID [8]byte
Reason string
ConfirmHashDigest [32]byte
ServiceMessage string
RequestedAt time.Time
ExecuteAt time.Time
}
type AccountDeletionResult struct {
User User
Changed bool
RevokedAuthorizations []Authorization
}
type AccountDeleteKind string
const (
AccountDeleteImmediate AccountDeleteKind = "immediate"
AccountDeleteDelayed AccountDeleteKind = "delayed"
)
type AccountDeleteOutcome struct {
Kind AccountDeleteKind
WaitSeconds int
ExecuteAt time.Time
Deletion AccountDeletionResult
}
type AccountDeletionCandidate struct {
UserID int64
Source AccountDeletionSource
DueAt time.Time
}
type AccountDeletionNotification struct {
ID int64
TargetUserID int64
DeletedUserID int64
Attempts int
}

View file

@ -1,5 +1,7 @@
package domain package domain
import "time"
// UserIDSequenceBase 是普通用户 ID 的起始值。 // UserIDSequenceBase 是普通用户 ID 的起始值。
// //
// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。 // 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。
@ -63,6 +65,15 @@ type User struct {
PhotoHasVideo bool PhotoHasVideo bool
LastSeenAt int LastSeenAt int
Status UserStatus Status UserStatus
// Deleted is the durable tombstone state. Deleted users remain addressable by
// ID so historical messages can render "Deleted Account", but all profile
// and reusable identity fields are cleared at the store boundary.
Deleted bool
DeletedAt int64
DeletionSource AccountDeletionSource
DeletionReason string
CreatedAt time.Time
AccountDeleteAt time.Time
} }
// PremiumActiveAt 报告用户在 nowUnix 秒)时刻是否为有效会员。 // PremiumActiveAt 报告用户在 nowUnix 秒)时刻是否为有效会员。
@ -81,6 +92,25 @@ func (u User) EmojiStatusActiveAt(now int64) bool {
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now
} }
// DeletedTombstone strips every viewer-dependent or personally identifying
// field while preserving the immutable id and lifecycle audit facts.
func (u User) DeletedTombstone() User {
if !u.Deleted {
return u
}
return User{
ID: u.ID,
AccessHash: u.AccessHash,
Deleted: true,
DeletedAt: u.DeletedAt,
DeletionSource: u.DeletionSource,
DeletionReason: u.DeletionReason,
CreatedAt: u.CreatedAt,
AccountDeleteAt: u.AccountDeleteAt,
Status: UserStatus{Kind: UserStatusEmpty},
}
}
// UserStatusKind is a protocol-neutral account presence state. // UserStatusKind is a protocol-neutral account presence state.
type UserStatusKind int type UserStatusKind int

View file

@ -29,6 +29,7 @@ const (
PurposeLoginEmailSetup Purpose = "login_email_setup" PurposeLoginEmailSetup Purpose = "login_email_setup"
PurposeLoginEmailChange Purpose = "login_email_change" PurposeLoginEmailChange Purpose = "login_email_change"
PurposeChangePhone Purpose = "change_phone" PurposeChangePhone Purpose = "change_phone"
PurposeConfirmPhone Purpose = "confirm_phone"
) )
type Request struct { type Request struct {
@ -46,7 +47,7 @@ func (r Request) Validate(now time.Time) error {
return fmt.Errorf("delivery id is empty or too long") return fmt.Errorf("delivery id is empty or too long")
} }
switch r.Purpose { switch r.Purpose {
case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone: case PurposeLoginEmail, PurposeLoginSMS, PurposeLoginEmailSetup, PurposeLoginEmailChange, PurposeChangePhone, PurposeConfirmPhone:
default: default:
return fmt.Errorf("unsupported delivery purpose %q", r.Purpose) return fmt.Errorf("unsupported delivery purpose %q", r.Purpose)
} }

View file

@ -15,6 +15,15 @@ import (
// registerAccount 注册 account.* RPC handler。 // registerAccount 注册 account.* RPC handler。
func (r *Router) registerAccount(d *tlprofile.Dispatcher) { func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
return r.onAccountDeleteAccount(ctx, req)
})
registerRPC[*tg.AccountSendConfirmPhoneCodeRequest](d, tlprofile.SemanticMethodAccountSendConfirmPhoneCode, func(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (any, error) {
return r.onAccountSendConfirmPhoneCode(ctx, req)
})
registerRPC[*tg.AccountConfirmPhoneRequest](d, tlprofile.SemanticMethodAccountConfirmPhone, func(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (any, error) {
return r.onAccountConfirmPhone(ctx, req)
})
registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) { registerRPC[*tg.AccountRegisterDeviceRequest](d, tlprofile.SemanticMethodAccountRegisterDevice, func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (any, error) {
return true, nil return true, nil
}) })
@ -902,7 +911,7 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
if err != nil { if err != nil {
return false, internalErr() return false, internalErr()
} }
if ttl.Days <= 0 { if ttl.Days <= 0 || ttl.Days > domain.MaxAccountTTLDays {
return false, tgerr400("TTL_DAYS_INVALID") return false, tgerr400("TTL_DAYS_INVALID")
} }
if svc, ok := r.accountSettingsSvc(); ok { if svc, ok := r.accountSettingsSvc(); ok {

View file

@ -0,0 +1,152 @@
package rpc
import (
"context"
"errors"
"fmt"
"time"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
)
type accountDeletionService interface {
DeleteAccount(ctx context.Context, userID int64, authKeyID [8]byte, reason string, password *domain.PasswordCheck, now time.Time) (domain.AccountDeleteOutcome, error)
SendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, hash string) (string, domain.AuthCodeDelivery, error)
ConfirmPhone(ctx context.Context, userID int64, authKeyID [8]byte, phoneCodeHash, code string, now time.Time) ([]domain.Authorization, error)
ResendConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, oldHash string) (string, domain.AuthCodeDelivery, bool, error)
CancelConfirmPhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, phone, hash string) (bool, error)
}
func (r *Router) accountDeletionSvc() (accountDeletionService, bool) {
svc, ok := r.deps.Account.(accountDeletionService)
return svc, ok
}
func (r *Router) onAccountDeleteAccount(ctx context.Context, req *tg.AccountDeleteAccountRequest) (bool, error) {
userID, authorized, passwordPending, err := r.currentOrPendingPasswordUserID(ctx)
if err != nil {
return false, internalErr()
}
if userID == 0 || (!authorized && !passwordPending) {
return false, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return false, internalErr()
}
authKeyID, ok := AuthKeyIDFrom(ctx)
if !ok || authKeyID == ([8]byte{}) {
return false, authKeyUnregisteredErr()
}
var password *domain.PasswordCheck
if check, present := req.GetPassword(); present {
converted := domainPasswordCheck(check)
password = &converted
}
outcome, err := svc.DeleteAccount(ctx, userID, authKeyID, req.Reason, password, time.Now().UTC())
if err != nil {
return false, accountDeletionErr(err)
}
if outcome.Kind == domain.AccountDeleteDelayed {
wait := outcome.WaitSeconds
if wait < 1 {
wait = 1
}
return false, tgerr.New(420, fmt.Sprintf("2FA_CONFIRM_WAIT_%d", wait))
}
r.finishDeletedAccountAuthorizations(ctx, userID, outcome.Deletion.RevokedAuthorizations)
r.invalidateRPCProjectionForUser(userID)
dispatchNotifications := func() {
dispatchCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r.runAccountLifecycleOnce(dispatchCtx, 500)
}
if !postresponse.Register(ctx, dispatchNotifications) {
go dispatchNotifications()
}
return true, nil
}
func (r *Router) onAccountSendConfirmPhoneCode(ctx context.Context, req *tg.AccountSendConfirmPhoneCodeRequest) (tg.AuthSentCodeClass, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || userID == 0 {
return nil, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return nil, internalErr()
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
hash, delivery, err := svc.SendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.Hash)
if err != nil {
return nil, accountDeletionErr(err)
}
return tgSMSSentCode(hash, delivery.Length), nil
}
func (r *Router) onAccountConfirmPhone(ctx context.Context, req *tg.AccountConfirmPhoneRequest) (bool, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if !authorized || userID == 0 {
return false, authKeyUnregisteredErr()
}
svc, ok := r.accountDeletionSvc()
if !ok {
return false, internalErr()
}
authKeyID, _ := AuthKeyIDFrom(ctx)
revoked, err := svc.ConfirmPhone(ctx, userID, authKeyID, req.PhoneCodeHash, req.PhoneCode, time.Now().UTC())
if err != nil {
return false, accountDeletionErr(err)
}
r.finishDeletedAccountAuthorizations(ctx, userID, revoked)
return true, nil
}
func (r *Router) finishDeletedAccountAuthorizations(ctx context.Context, userID int64, revoked []domain.Authorization) {
current, _ := AuthKeyIDFrom(ctx)
for _, authorization := range revoked {
a := authorization
finish := func() {
r.discardSecretChatsForAuthKey(context.Background(), businessAuthKeyInt64(a.AuthKeyID), userID)
r.revokeAuthKeySessions(a.AuthKeyID)
}
if a.AuthKeyID == current {
if postresponse.Register(ctx, finish) {
continue
}
}
finish()
}
}
func accountDeletionErr(err error) error {
switch {
case errors.Is(err, domain.ErrPasswordHashInvalid), errors.Is(err, domain.ErrSRPIDInvalid), errors.Is(err, domain.ErrSRPPasswordChanged):
return passwordErr(err)
case errors.Is(err, domain.ErrAccountDeletionHashInvalid), errors.Is(err, domain.ErrAccountDeletionNotPending):
return tgerr.New(400, "HASH_INVALID")
case errors.Is(err, domain.ErrPhoneCodeEmpty):
return phoneCodeEmptyErr()
case errors.Is(err, domain.ErrPhoneCodeInvalid):
return phoneCodeInvalidErr()
case errors.Is(err, domain.ErrPhoneCodeExpired):
return phoneCodeExpiredErr()
case errors.Is(err, domain.ErrAccountDeletionForbidden):
return botMethodInvalidErr()
case errors.Is(err, domain.ErrAccountDeleted):
return authKeyUnregisteredErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,177 @@
package rpc
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
"telesrv/internal/store/memory"
)
func TestAccountDeleteRPCDeliversResultBeforeClosingCurrentSession(t *testing.T) {
current := [8]byte{1}
other := [8]byte{2}
accountSvc := &rpcDeletionAccountService{
Service: appaccount.NewService(memory.NewPasswordStore()),
outcome: domain.AccountDeleteOutcome{
Kind: domain.AccountDeleteImmediate,
Deletion: domain.AccountDeletionResult{Changed: true, RevokedAuthorizations: []domain.Authorization{
{AuthKeyID: current, UserID: 42},
{AuthKeyID: other, UserID: 42},
}},
},
}
sessions := &deletionCaptureSessions{}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
ctx := postresponse.WithCallbacks(WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 42), current), 77))
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "manual"})
if err != nil || !ok {
t.Fatalf("delete account ok=%v err=%v", ok, err)
}
if sessions.wasClosed(current) {
t.Fatal("current auth key closed before rpc_result delivery")
}
if !sessions.wasClosed(other) {
t.Fatal("other auth key was not revoked immediately")
}
postresponse.Run(ctx)
if !sessions.wasClosed(current) {
t.Fatal("current auth key not closed after rpc_result delivery")
}
}
func TestAccountDeleteRPCMapsDelayedTwoFAWait(t *testing.T) {
accountSvc := &rpcDeletionAccountService{
Service: appaccount.NewService(memory.NewPasswordStore()),
outcome: domain.AccountDeleteOutcome{Kind: domain.AccountDeleteDelayed, WaitSeconds: 604800},
}
r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System)
ctx := WithAuthKeyID(WithUserID(context.Background(), 42), [8]byte{1})
ok, err := r.onAccountDeleteAccount(ctx, &tg.AccountDeleteAccountRequest{Reason: "Forgot password"})
if ok || !tgerr.Is(err, "2FA_CONFIRM_WAIT") || !strings.Contains(err.Error(), "604800") {
t.Fatalf("delayed delete ok=%v err=%v", ok, err)
}
}
func TestDeleteAccountAllowedWithoutFullAuthorization(t *testing.T) {
if !rpcAllowedWithoutAuthorization(tg.AccountDeleteAccountRequestTypeID) {
t.Fatal("account.deleteAccount must reach the narrow password_pending identity resolver")
}
if rpcAllowedWithoutAuthorization(tg.AccountConfirmPhoneRequestTypeID) || rpcAllowedWithoutAuthorization(tg.AccountSendConfirmPhoneCodeRequestTypeID) {
t.Fatal("confirm-phone methods must remain fully authorized")
}
}
func TestAccountDeletionNotificationCompletesForOfflineTarget(t *testing.T) {
sessions := &offlineDeletionSessions{}
svc := &deletionWorkerService{}
r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System)
r.dispatchAccountDeletionNotification(context.Background(), svc, domain.AccountDeletionNotification{
ID: 9, TargetUserID: 42, DeletedUserID: 77, Attempts: 1,
})
if len(svc.completed) != 1 || svc.completed[0] != 9 {
t.Fatalf("completed notifications = %v, want [9]", svc.completed)
}
}
func TestAccountLifecyclePartialSweepFinishesCommittedDeletion(t *testing.T) {
revoked := [8]byte{3}
svc := &rpcDeletionAccountService{
Service: appaccount.NewService(memory.NewPasswordStore()),
sweepResults: []domain.AccountDeletionResult{{
Changed: true,
User: domain.User{ID: 42, Deleted: true},
RevokedAuthorizations: []domain.Authorization{{AuthKeyID: revoked, UserID: 42}},
}},
sweepErr: errors.New("later candidate failed"),
}
sessions := &deletionCaptureSessions{}
r := New(Config{}, Deps{Account: svc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
r.runAccountLifecycleOnce(context.Background(), 10)
if !sessions.wasClosed(revoked) {
t.Fatal("committed deletion authorization was not closed after partial sweep failure")
}
}
type rpcDeletionAccountService struct {
*appaccount.Service
outcome domain.AccountDeleteOutcome
err error
sweepResults []domain.AccountDeletionResult
sweepErr error
}
func (s *rpcDeletionAccountService) DeleteAccount(context.Context, int64, [8]byte, string, *domain.PasswordCheck, time.Time) (domain.AccountDeleteOutcome, error) {
return s.outcome, s.err
}
func (*rpcDeletionAccountService) SendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string) (string, domain.AuthCodeDelivery, error) {
return "hash", domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: 5}, nil
}
func (*rpcDeletionAccountService) ConfirmPhone(context.Context, int64, [8]byte, string, string, time.Time) ([]domain.Authorization, error) {
return nil, nil
}
func (*rpcDeletionAccountService) ResendConfirmPhoneCode(context.Context, int64, [8]byte, int64, string, string) (string, domain.AuthCodeDelivery, bool, error) {
return "", domain.AuthCodeDelivery{}, false, nil
}
func (*rpcDeletionAccountService) CancelConfirmPhoneCode(context.Context, int64, [8]byte, string, string) (bool, error) {
return false, nil
}
func (s *rpcDeletionAccountService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
return s.sweepResults, s.sweepErr
}
type deletionCaptureSessions struct {
captureSessions
closed [][8]byte
}
type offlineDeletionSessions struct{ captureSessions }
func (*offlineDeletionSessions) PushToUserExceptAuthKeySession(context.Context, int64, [8]byte, int64, proto.MessageType, tg.UpdatesClass) (int, error) {
return 0, nil
}
type deletionWorkerService struct{ completed []int64 }
func (*deletionWorkerService) SweepDueAccountDeletions(context.Context, time.Time, int) ([]domain.AccountDeletionResult, error) {
return nil, nil
}
func (*deletionWorkerService) ClaimAccountDeletionNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountDeletionNotification, error) {
return nil, nil
}
func (s *deletionWorkerService) CompleteAccountDeletionNotification(_ context.Context, id int64, _ time.Time) error {
s.completed = append(s.completed, id)
return nil
}
func (s *deletionCaptureSessions) CloseSessionsForBusinessAuthKey(id [8]byte) int {
s.closed = append(s.closed, id)
return 1
}
func (s *deletionCaptureSessions) wasClosed(id [8]byte) bool {
for _, closed := range s.closed {
if closed == id {
return true
}
}
return false
}

View file

@ -0,0 +1,99 @@
package rpc
import (
"context"
"time"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
"telesrv/internal/domain"
)
type accountLifecycleWorkerService interface {
SweepDueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionResult, error)
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
}
// RunAccountLifecycle executes all due account deletion sources through one
// tombstone path and drains the durable non-pts updateUser queue. The queue is
// a crash-safe, bounded online nudge: offline users are completed after the
// first attempt because getDialogs/getHistory hydration independently returns
// the authoritative tombstone. This avoids an immortal retry queue for a
// non-pts update that cannot participate in getDifference.
func (r *Router) RunAccountLifecycle(ctx context.Context, interval time.Duration, batch int) {
if interval <= 0 {
interval = time.Minute
}
if batch <= 0 {
batch = 500
}
r.runAccountLifecycleOnce(ctx, batch)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.runAccountLifecycleOnce(ctx, batch)
}
}
}
func (r *Router) runAccountLifecycleOnce(ctx context.Context, batch int) {
svc, ok := r.deps.Account.(accountLifecycleWorkerService)
if !ok {
return
}
now := r.clock.Now().UTC()
sweepCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
results, err := svc.SweepDueAccountDeletions(sweepCtx, now, batch)
cancel()
for _, result := range results {
if !result.Changed {
continue
}
r.invalidateRPCProjectionForUser(result.User.ID)
r.finishDeletedAccountAuthorizations(context.Background(), result.User.ID, result.RevokedAuthorizations)
}
if err != nil {
// SweepDueAccountDeletions may return already-committed results before a
// later candidate fails. Always finish those sessions/caches and drain
// their durable notifications; the failed and remaining candidates are
// retried from their authoritative due rows on the next tick.
r.log.Warn("account lifecycle deletion sweep partially failed", zap.Int("completed", len(results)), zap.Error(err))
}
for {
claimCtx, claimCancel := context.WithTimeout(ctx, 30*time.Second)
notifications, err := svc.ClaimAccountDeletionNotifications(claimCtx, now, batch, 2*time.Minute)
claimCancel()
if err != nil {
r.log.Warn("claim account deletion notifications failed", zap.Error(err))
return
}
for _, notification := range notifications {
r.dispatchAccountDeletionNotification(ctx, svc, notification)
}
if len(notifications) < batch {
return
}
}
}
func (r *Router) dispatchAccountDeletionNotification(ctx context.Context, svc accountLifecycleWorkerService, notification domain.AccountDeletionNotification) {
now := r.clock.Now().UTC()
updates := &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.DeletedUserID}},
Users: []tg.UserClass{tgUser(domain.User{
ID: notification.DeletedUserID,
Deleted: true,
})},
Date: int(now.Unix()),
}
r.pushUserUpdates(ctx, notification.TargetUserID, updates)
if err := svc.CompleteAccountDeletionNotification(ctx, notification.ID, now); err != nil {
r.log.Warn("complete account deletion notification failed", zap.Int64("notification_id", notification.ID), zap.Error(err))
}
}

View file

@ -490,6 +490,19 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil { if err := r.checkAuthCodeRateLimit(ctx, req.PhoneNumber); err != nil {
return nil, err return nil, err
} }
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
if svc, ok := r.deps.Account.(accountDeletionService); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
hash, delivery, handled, err := svc.ResendConfirmPhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber, req.PhoneCodeHash)
if handled {
if err != nil {
return nil, accountDeletionErr(err)
}
return tgSMSSentCode(hash, delivery.Length), nil
}
}
}
var hash string var hash string
var err error var err error
if scoped, ok := r.deps.Auth.(interface { if scoped, ok := r.deps.Auth.(interface {
@ -507,6 +520,18 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
} }
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) { func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
if userID, authorized, err := r.currentUserID(ctx); err == nil && authorized && userID != 0 {
if svc, ok := r.deps.Account.(accountDeletionService); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
handled, err := svc.CancelConfirmPhoneCode(ctx, userID, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
if handled {
if err != nil {
return false, accountDeletionErr(err)
}
return true, nil
}
}
}
var err error var err error
if scoped, ok := r.deps.Auth.(interface { if scoped, ok := r.deps.Auth.(interface {
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error CancelCodeForAuthKey(context.Context, [8]byte, string, string) error

View file

@ -32,6 +32,9 @@ func rpcAllowedWithoutAuthorization(id uint32) bool {
tg.AuthReportMissingCodeRequestTypeID, tg.AuthReportMissingCodeRequestTypeID,
tg.AuthResetLoginEmailRequestTypeID, tg.AuthResetLoginEmailRequestTypeID,
tg.AccountGetPasswordRequestTypeID, tg.AccountGetPasswordRequestTypeID,
// deleteAccount may complete the narrow password_pending login path when
// the user forgot 2FA. The handler resolves only that bound identity.
tg.AccountDeleteAccountRequestTypeID,
// 登录邮箱 setupemailVerifyPurposeLoginSetup发生在登录流程中、尚未鉴权 // 登录邮箱 setupemailVerifyPurposeLoginSetup发生在登录流程中、尚未鉴权
// 故这两个 account.* 方法必须放行 pre-authloginChange 分支内部仍校验 userID。 // 故这两个 account.* 方法必须放行 pre-authloginChange 分支内部仍校验 userID。
tg.AccountSendVerifyEmailCodeRequestTypeID, tg.AccountSendVerifyEmailCodeRequestTypeID,

View file

@ -9,6 +9,9 @@ import (
// tgSelfUser 把 domain.User 转为 self 标记的 tg.Useroptional 字段由 Encode 自动 SetFlags // tgSelfUser 把 domain.User 转为 self 标记的 tg.Useroptional 字段由 Encode 自动 SetFlags
func tgSelfUser(u domain.User) *tg.User { func tgSelfUser(u domain.User) *tg.User {
if u.Deleted {
return &tg.User{ID: u.ID, Deleted: true}
}
out := &tg.User{ out := &tg.User{
ID: u.ID, ID: u.ID,
AccessHash: u.AccessHash, AccessHash: u.AccessHash,
@ -34,6 +37,9 @@ func tgSelfUser(u domain.User) *tg.User {
} }
func tgUser(u domain.User) *tg.User { func tgUser(u domain.User) *tg.User {
if u.Deleted {
return &tg.User{ID: u.ID, Deleted: true}
}
out := &tg.User{ out := &tg.User{
ID: u.ID, ID: u.ID,
AccessHash: u.AccessHash, AccessHash: u.AccessHash,

View file

@ -0,0 +1,56 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
func TestDeletedUserTLProjectionContainsOnlyTombstoneIdentity(t *testing.T) {
u := domain.User{
ID: 42, AccessHash: 99, Phone: "secret", FirstName: "Alice", LastName: "Private",
Username: "released", About: "hidden", Verified: true, PremiumUntil: 2_000_000_000,
PhotoID: 123, Deleted: true, DeletedAt: 1_800_000_000,
}
got := tgUser(u)
if got.ID != u.ID || !got.Deleted {
t.Fatalf("deleted user = %+v", got)
}
if got.AccessHash != 0 || got.Phone != "" || got.FirstName != "" || got.LastName != "" || got.Username != "" || got.Verified || got.Premium || got.Photo != nil || got.Status != nil || len(got.Usernames) != 0 {
t.Fatalf("deleted user leaked profile state: %+v", got)
}
self := tgSelfUser(u)
if !self.Deleted || self.Self || self.ID != u.ID {
t.Fatalf("deleted self projection = %+v", self)
}
}
func TestHistoryHydrationReplacesStaleUserWithDeletedTombstone(t *testing.T) {
viewer := domain.User{ID: 7, FirstName: "Viewer"}
deleted := domain.User{ID: 42, AccessHash: 99, Deleted: true, DeletedAt: 1_800_000_000}
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{
viewer.ID: viewer, deleted.ID: deleted,
}}}, zaptest.NewLogger(t), clock.System)
list := r.enrichMessageList(context.Background(), viewer.ID, domain.MessageList{
Messages: []domain.Message{{
OwnerUserID: viewer.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
From: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
Body: "retained history",
}},
// Simulate an old denormalized message query row. The authoritative
// Users.ByIDs hydration must replace it, not keep an empty active user.
Users: []domain.User{{ID: deleted.ID, Phone: "stale", FirstName: "Stale"}},
})
if len(list.Users) != 1 || !list.Users[0].Deleted || list.Users[0].Phone != "" || list.Users[0].FirstName != "" {
t.Fatalf("history users = %+v, want authoritative tombstone", list.Users)
}
if got := tgUser(list.Users[0]); !got.Deleted || got.ID != deleted.ID {
t.Fatalf("history TL user = %+v", got)
}
}

View file

@ -0,0 +1,22 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
// AccountLifecycleStore owns the atomic boundary between tombstoning a user,
// purging private account state, revoking authorizations and enqueueing
// non-pts updateUser notifications.
type AccountLifecycleStore interface {
AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error)
ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error)
PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error)
ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error)
CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error)
DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error)
ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error)
CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error
}

View file

@ -10,6 +10,7 @@ import (
const ( const (
PhoneCodePurposeChangePhone = "change_phone" PhoneCodePurposeChangePhone = "change_phone"
PhoneCodePurposeConfirmPhone = "confirm_phone"
PhoneCodeChannelPhone = "phone" PhoneCodeChannelPhone = "phone"
PhoneCodeChannelSMS = "sms" PhoneCodeChannelSMS = "sms"
PhoneCodeChannelEmailLogin = "email_login" PhoneCodeChannelEmailLogin = "email_login"
@ -75,6 +76,10 @@ type PhoneCode struct {
VerifiedEmail bool VerifiedEmail bool
RequireSignUp bool RequireSignUp bool
LoginEmailHash string LoginEmailHash string
// AccountDeletionHash is the hex-encoded SHA-256 digest of the validated
// confirmphone link token. It binds account.confirmPhone to one pending
// deletion without persisting the raw link credential in the code record.
AccountDeletionHash string
} }
type PhoneCodeSnapshot struct { type PhoneCodeSnapshot struct {

View file

@ -6,6 +6,7 @@ import (
"strings" "strings"
"sync" "sync"
"telesrv/internal/domain" "telesrv/internal/domain"
"time"
) )
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。 // UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
@ -65,7 +66,7 @@ func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool,
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for _, u := range s.byID { for _, u := range s.byID {
if u.Phone == phone { if !u.Deleted && u.Phone == phone {
return u, true, nil return u, true, nil
} }
} }
@ -87,6 +88,9 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User,
out := make([]domain.User, 0, len(want)) out := make([]domain.User, 0, len(want))
seenIDs := map[int64]struct{}{} seenIDs := map[int64]struct{}{}
for _, u := range s.byID { for _, u := range s.byID {
if u.Deleted {
continue
}
if _, ok := want[u.Phone]; !ok { if _, ok := want[u.Phone]; !ok {
continue continue
} }
@ -108,7 +112,7 @@ func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User,
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for _, u := range s.byID { for _, u := range s.byID {
if strings.ToLower(u.Username) == username { if !u.Deleted && strings.ToLower(u.Username) == username {
return u, true, nil return u, true, nil
} }
} }
@ -123,7 +127,7 @@ func (s *UserStore) CheckUsername(_ context.Context, userID int64, username stri
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for id, u := range s.byID { for id, u := range s.byID {
if strings.ToLower(u.Username) == username && id != userID { if !u.Deleted && strings.ToLower(u.Username) == username && id != userID {
return false, nil return false, nil
} }
} }
@ -143,7 +147,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
defer s.mu.RUnlock() defer s.mu.RUnlock()
users := make([]domain.User, 0) users := make([]domain.User, 0)
for _, u := range s.byID { for _, u := range s.byID {
if u.ID == currentUserID { if u.ID == currentUserID || u.Deleted {
continue continue
} }
if userMatchesSearch(u, query, phoneQuery) { if userMatchesSearch(u, query, phoneQuery) {
@ -165,7 +169,7 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.User{}, domain.ErrUsernameNotOccupied return domain.User{}, domain.ErrUsernameNotOccupied
} }
if usernameLower != "" { if usernameLower != "" {
@ -184,7 +188,7 @@ func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, la
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.User{}, domain.ErrUsernameNotOccupied return domain.User{}, domain.ErrUsernameNotOccupied
} }
u.FirstName = firstName u.FirstName = firstName
@ -198,7 +202,7 @@ func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday dom
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound return domain.User{}, domain.ErrUserNotFound
} }
u.Birthday = birthday u.Birthday = birthday
@ -210,7 +214,7 @@ func (s *UserStore) UpdatePersonalChannel(_ context.Context, userID int64, chann
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound return domain.User{}, domain.ErrUserNotFound
} }
u.PersonalChannelID = channelID u.PersonalChannelID = channelID
@ -224,7 +228,7 @@ func (s *UserStore) bumpBotInfoVersion(userID int64) (int, bool) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok || !u.Bot { if !ok || u.Deleted || !u.Bot {
return 0, false return 0, false
} }
u.BotInfoVersion++ u.BotInfoVersion++
@ -237,7 +241,7 @@ func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, se
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok || !u.Bot { if !ok || u.Deleted || !u.Bot {
return false return false
} }
if setName { if setName {
@ -255,7 +259,7 @@ func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound return domain.User{}, domain.ErrUserNotFound
} }
if until < 0 { if until < 0 {
@ -271,7 +275,7 @@ func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool)
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound return domain.User{}, domain.ErrUserNotFound
} }
u.Verified = verified u.Verified = verified
@ -288,7 +292,7 @@ func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int)
defer s.mu.Unlock() defer s.mu.Unlock()
out := make([]domain.User, 0) out := make([]domain.User, 0)
for id, u := range s.byID { for id, u := range s.byID {
if u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now { if u.Deleted || u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now {
continue continue
} }
u.PremiumUntil = 0 u.PremiumUntil = 0
@ -307,7 +311,7 @@ func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentI
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound return domain.User{}, domain.ErrUserNotFound
} }
if documentID == 0 { if documentID == 0 {
@ -323,7 +327,7 @@ func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.User{}, domain.ErrUserNotFound return domain.User{}, domain.ErrUserNotFound
} }
if forProfile { if forProfile {
@ -342,7 +346,7 @@ func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt i
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
u, ok := s.byID[userID] u, ok := s.byID[userID]
if !ok { if !ok || u.Deleted {
return domain.ErrUsernameNotOccupied return domain.ErrUsernameNotOccupied
} }
if lastSeenAt > u.LastSeenAt { if lastSeenAt > u.LastSeenAt {
@ -379,6 +383,9 @@ func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error
} }
u.ID = s.nextID u.ID = s.nextID
s.nextID++ s.nextID++
if u.CreatedAt.IsZero() {
u.CreatedAt = time.Now().UTC()
}
s.byID[u.ID] = u s.byID[u.ID] = u
return u, nil return u, nil
} }

View file

@ -0,0 +1,737 @@
package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// AccountLifecycleStore is the PostgreSQL implementation of the unified
// account tombstone, delayed deletion and deletion notification boundary.
type AccountLifecycleStore struct {
pool *pgxpool.Pool
}
func NewAccountLifecycleStore(pool *pgxpool.Pool) *AccountLifecycleStore {
return &AccountLifecycleStore{pool: pool}
}
func (s *AccountLifecycleStore) AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error) {
if s == nil || s.pool == nil || userID == 0 {
return domain.AccountDeletionSnapshot{}, false, nil
}
u, found, err := NewUserStore(s.pool).ByID(ctx, userID)
if err != nil || !found {
return domain.AccountDeletionSnapshot{}, found, err
}
var snapshot = domain.AccountDeletionSnapshot{User: u}
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM account_passwords WHERE user_id = $1 AND has_password),
COALESCE((SELECT password_changed_at FROM account_passwords WHERE user_id = $1), u.created_at)
FROM users u WHERE u.id = $1`, userID).Scan(&snapshot.HasPassword, &snapshot.PasswordUpdatedAt); err != nil {
return domain.AccountDeletionSnapshot{}, false, fmt.Errorf("load account deletion password facts: %w", err)
}
pending, ok, err := pendingAccountDeletion(ctx, s.pool, userID, nil)
if err != nil {
return domain.AccountDeletionSnapshot{}, false, err
}
if ok {
snapshot.Pending = &pending
}
return snapshot, true, nil
}
func (s *AccountLifecycleStore) ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
if s == nil || s.pool == nil || req.UserID == 0 || req.RequestedAt.IsZero() || !req.ExecuteAt.After(req.RequestedAt) {
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeletionForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("begin schedule account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, req.UserID, domain.OfficialSystemUserID); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock schedule account deletion users: %w", err)
}
var deletedAt *time.Time
if err := tx.QueryRow(ctx, `SELECT deleted_at FROM users WHERE id = $1 FOR UPDATE`, req.UserID).Scan(&deletedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, domain.ErrUserNotFound
}
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock account deletion user: %w", err)
}
if deletedAt != nil {
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeleted
}
if existing, ok, err := pendingAccountDeletion(ctx, tx, req.UserID, nil); err != nil {
return domain.AccountDeletionRequest{}, false, err
} else if ok {
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit existing account deletion: %w", err)
}
return existing, false, nil
}
row := tx.QueryRow(ctx, `
INSERT INTO account_deletion_requests (
user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at`,
req.UserID, authKeyIDToInt64(req.RequesterAuthKeyID), req.Reason,
req.ConfirmHashDigest[:], req.RequestedAt, req.ExecuteAt)
pending, err := scanAccountDeletionRequest(row)
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("insert account deletion request: %w", err)
}
randomID := int64(binary.LittleEndian.Uint64(req.ConfirmHashDigest[:8]))
if randomID == 0 {
randomID = pending.ID
}
if _, err := NewMessageStore(tx).SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: domain.OfficialSystemUserID,
RecipientUserID: req.UserID,
RandomID: randomID,
Message: req.ServiceMessage,
Date: int(req.RequestedAt.Unix()),
}); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("send account deletion confirmation message: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit schedule account deletion: %w", err)
}
return pending, true, nil
}
func (s *AccountLifecycleStore) PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
if s == nil || s.pool == nil || userID == 0 {
return domain.AccountDeletionRequest{}, false, nil
}
return pendingAccountDeletion(ctx, s.pool, userID, digest[:])
}
func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
if s == nil || s.pool == nil || userID == 0 || now.IsZero() || !validAccountDeletionSource(source) {
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("begin execute account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion user: %w", err)
}
var lockedID int64
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
}
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion row: %w", err)
}
u, found, err := NewUserStore(tx).ByID(ctx, userID)
if err != nil {
return domain.AccountDeletionResult{}, err
}
if !found {
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
}
if u.Deleted {
return domain.AccountDeletionResult{User: u, Changed: false}, nil
}
if u.Bot || domain.IsSystemUserID(u.ID) {
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
}
due, err := accountDeletionStillDue(ctx, tx, u, source, now)
if err != nil {
return domain.AccountDeletionResult{}, err
}
if !due {
return domain.AccountDeletionResult{User: u, Changed: false}, nil
}
if err := enqueueAccountDeletionNotifications(ctx, tx, userID); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := settleDeletedAccountFinancialState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
revoked, err := revokeByUserExceptTx(ctx, tx, userID, 0)
if err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("revoke deleted account authorizations: %w", err)
}
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
}
reason = strings.TrimSpace(reason)
reason = truncateUTF8Bytes(reason, 1024)
if _, err := tx.Exec(ctx, `
UPDATE users SET
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, last_seen_at = 0,
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
deleted_at = $2, deletion_source = $3, deletion_reason = $4,
account_delete_at = NULL, updated_at = $2
WHERE id = $1 AND deleted_at IS NULL`, userID, now, string(source), reason); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("write deleted account tombstone: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE account_deletion_requests
SET state = 'executed', completed_at = $2, updated_at = $2
WHERE user_id = $1 AND state = 'pending'`, userID, now); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("complete account deletion request: %w", err)
}
u, found, err = NewUserStore(tx).ByID(ctx, userID)
if err != nil || !found {
if err == nil {
err = domain.ErrUserNotFound
}
return domain.AccountDeletionResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("commit execute account deletion: %w", err)
}
return domain.AccountDeletionResult{User: u, Changed: true, RevokedAuthorizations: revoked}, nil
}
func (s *AccountLifecycleStore) CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error) {
if s == nil || s.pool == nil || userID == 0 || now.IsZero() {
return nil, domain.ErrAccountDeletionHashInvalid
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin cancel account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
return nil, fmt.Errorf("lock cancel account deletion: %w", err)
}
pending, ok, err := pendingAccountDeletionForUpdate(ctx, tx, userID, digest[:])
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrAccountDeletionHashInvalid
}
revoked, err := revokeOneAuthorizationTx(ctx, tx, userID, pending.RequesterAuthKeyID)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE account_deletion_requests
SET state = 'cancelled', completed_at = $2, updated_at = $2
WHERE id = $1 AND state = 'pending'`, pending.ID, now); err != nil {
return nil, fmt.Errorf("cancel account deletion request: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit cancel account deletion: %w", err)
}
return revoked, nil
}
func (s *AccountLifecycleStore) DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error) {
if s == nil || s.pool == nil || limit <= 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
WITH candidates AS (
SELECT user_id, 'password_reset_expiry'::text AS source, execute_at AS due_at, 1 AS priority
FROM account_deletion_requests WHERE state = 'pending' AND execute_at <= $1
UNION ALL
SELECT id, 'account_ttl', account_delete_at, 2
FROM users WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at <= $1
UNION ALL
SELECT r.user_id, 'freeze_expiry', r.frozen_until, 3
FROM account_restrictions r JOIN users u ON u.id = r.user_id
WHERE r.frozen = true AND r.frozen_until IS NOT NULL AND r.frozen_until <= $1
AND u.deleted_at IS NULL AND u.is_bot = false
), dedup AS (
SELECT DISTINCT ON (user_id) user_id, source, due_at
FROM candidates ORDER BY user_id, priority, due_at
)
SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, now, limit)
if err != nil {
return nil, fmt.Errorf("list due account deletions: %w", err)
}
defer rows.Close()
out := make([]domain.AccountDeletionCandidate, 0)
for rows.Next() {
var c domain.AccountDeletionCandidate
var source string
if err := rows.Scan(&c.UserID, &source, &c.DueAt); err != nil {
return nil, fmt.Errorf("scan due account deletion: %w", err)
}
c.Source = domain.AccountDeletionSource(source)
out = append(out, c)
}
return out, rows.Err()
}
func (s *AccountLifecycleStore) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
if s == nil || s.pool == nil || limit <= 0 || lease <= 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
WITH claim AS (
SELECT id FROM account_deletion_notifications
WHERE (status = 'pending' AND next_attempt_at <= $1)
OR (status = 'dispatching' AND lease_until <= $1)
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
)
UPDATE account_deletion_notifications n
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
FROM claim WHERE n.id = claim.id
RETURNING n.id, n.target_user_id, n.deleted_user_id, n.attempts`, now, limit, now.Add(lease))
if err != nil {
return nil, fmt.Errorf("claim account deletion notifications: %w", err)
}
defer rows.Close()
out := make([]domain.AccountDeletionNotification, 0)
for rows.Next() {
var n domain.AccountDeletionNotification
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.DeletedUserID, &n.Attempts); err != nil {
return nil, fmt.Errorf("scan account deletion notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *AccountLifecycleStore) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE account_deletion_notifications
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $2 WHERE id = $1`, id, now)
if err != nil {
return fmt.Errorf("complete account deletion notification: %w", err)
}
return nil
}
type accountDeletionRowScanner interface {
Scan(dest ...any) error
}
func scanAccountDeletionRequest(row accountDeletionRowScanner) (domain.AccountDeletionRequest, error) {
var (
r domain.AccountDeletionRequest
authKey int64
state string
digest []byte
completedAt *time.Time
)
if err := row.Scan(&r.ID, &r.UserID, &authKey, &state, &r.Reason, &digest,
&r.RequestedAt, &r.ExecuteAt, &completedAt); err != nil {
return domain.AccountDeletionRequest{}, err
}
if len(digest) != len(r.ConfirmHashDigest) {
return domain.AccountDeletionRequest{}, fmt.Errorf("invalid account deletion digest length %d", len(digest))
}
copy(r.ConfirmHashDigest[:], digest)
r.RequesterAuthKeyID = authKeyIDFromInt64(authKey)
r.State = domain.AccountDeletionRequestState(state)
if completedAt != nil {
r.CompletedAt = *completedAt
}
return r, nil
}
func pendingAccountDeletion(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
query := `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at FROM account_deletion_requests
WHERE user_id = $1 AND state = 'pending'`
args := []any{userID}
if digest != nil {
query += ` AND confirm_hash_digest = $2`
args = append(args, digest)
}
r, err := scanAccountDeletionRequest(db.QueryRow(ctx, query, args...))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, nil
}
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("load pending account deletion: %w", err)
}
return r, true, nil
}
func pendingAccountDeletionForUpdate(ctx context.Context, tx pgx.Tx, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
row := tx.QueryRow(ctx, `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at FROM account_deletion_requests
WHERE user_id = $1 AND confirm_hash_digest = $2 AND state = 'pending' FOR UPDATE`, userID, digest)
r, err := scanAccountDeletionRequest(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, nil
}
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock pending account deletion: %w", err)
}
return r, true, nil
}
func validAccountDeletionSource(source domain.AccountDeletionSource) bool {
switch source {
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline,
domain.AccountDeletionPasswordResetExpiry, domain.AccountDeletionAccountTTL, domain.AccountDeletionFreezeExpiry:
return true
default:
return false
}
}
// accountDeletionStillDue closes the list-then-execute race for every scheduled
// source. The user row is already locked; source-specific facts are read and,
// where applicable, locked again immediately before destructive work begins.
// Manual sources are admission decisions made by the caller and have no
// independently mutable deadline to revalidate.
func accountDeletionStillDue(ctx context.Context, tx pgx.Tx, user domain.User, source domain.AccountDeletionSource, now time.Time) (bool, error) {
switch source {
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline:
return true, nil
case domain.AccountDeletionPasswordResetExpiry:
var requestID int64
err := tx.QueryRow(ctx, `
SELECT id FROM account_deletion_requests
WHERE user_id = $1 AND state = 'pending' AND execute_at <= $2
ORDER BY execute_at, id LIMIT 1 FOR UPDATE`, user.ID, now).Scan(&requestID)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("revalidate pending account deletion: %w", err)
}
return true, nil
case domain.AccountDeletionAccountTTL:
return !user.AccountDeleteAt.IsZero() && !user.AccountDeleteAt.After(now), nil
case domain.AccountDeletionFreezeExpiry:
var frozen bool
var frozenUntil *time.Time
err := tx.QueryRow(ctx, `
SELECT frozen, frozen_until FROM account_restrictions WHERE user_id = $1 FOR UPDATE`, user.ID).Scan(&frozen, &frozenUntil)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("revalidate frozen account deletion: %w", err)
}
return frozen && frozenUntil != nil && !frozenUntil.After(now), nil
default:
return false, domain.ErrAccountDeletionForbidden
}
}
func truncateUTF8Bytes(value string, maxBytes int) string {
if maxBytes < 1 {
return ""
}
if len(value) <= maxBytes {
return value
}
cut := maxBytes
for cut > 0 && !utf8.ValidString(value[:cut]) {
cut--
}
return value[:cut]
}
func enqueueAccountDeletionNotifications(ctx context.Context, tx pgx.Tx, userID int64) error {
const maxAccountDeletionNotificationAudience = 4096
_, err := tx.Exec(ctx, `
INSERT INTO account_deletion_notifications (target_user_id, deleted_user_id)
SELECT audience.user_id, $1
FROM (
SELECT user_id
FROM (
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
FROM contacts WHERE user_id = $1
UNION ALL
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
UNION ALL
SELECT peer_id, 1, top_message_date
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
UNION ALL
SELECT user_id, 1, top_message_date
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
) candidates
GROUP BY user_id
ORDER BY min(priority), max(activity) DESC, user_id
LIMIT $2
) audience
JOIN users u ON u.id = audience.user_id
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
ON CONFLICT (target_user_id, deleted_user_id) DO NOTHING`, userID, maxAccountDeletionNotificationAudience)
if err != nil {
return fmt.Errorf("enqueue account deletion notifications: %w", err)
}
return nil
}
func revokeOneAuthorizationTx(ctx context.Context, tx pgx.Tx, userID int64, authKeyID [8]byte) ([]domain.Authorization, error) {
id := authKeyIDToInt64(authKeyID)
if id == 0 {
return nil, nil
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{id}); err != nil {
return nil, err
}
var locked int64
if err := tx.QueryRow(ctx, `SELECT auth_key_id FROM auth_keys WHERE auth_key_id = $1 FOR UPDATE`, id).Scan(&locked); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("lock password reset auth key: %w", err)
}
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, userID))
if err != nil {
return nil, fmt.Errorf("load password reset authorization: %w", err)
}
if !found {
return nil, nil
}
if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil {
return nil, err
}
return []domain.Authorization{a}, nil
}
func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
// Leave shared private_messages/channel_messages and immutable transaction
// ledgers intact. Only the deleted user's private projections and settings are
// removed; other users continue to reference the tombstone sender.
statements := []string{
`DELETE FROM account_privacy_rules WHERE owner_user_id = $1`,
`DELETE FROM account_reaction_settings WHERE user_id = $1`,
`DELETE FROM account_restrictions WHERE user_id = $1`,
`DELETE FROM account_settings WHERE user_id = $1`,
`DELETE FROM account_passwords WHERE user_id = $1`,
`DELETE FROM notify_settings WHERE owner_user_id = $1`,
`DELETE FROM passkey_credentials WHERE user_id = $1`,
`DELETE FROM contacts WHERE user_id = $1 OR contact_user_id = $1`,
`DELETE FROM contact_blocks WHERE owner_user_id = $1 OR blocked_user_id = $1`,
`DELETE FROM dialog_drafts WHERE user_id = $1`,
`DELETE FROM dialog_filter_settings WHERE user_id = $1`,
`DELETE FROM dialog_filters WHERE user_id = $1`,
`DELETE FROM chatlist_memberships WHERE user_id = $1 OR owner_user_id = $1`,
`DELETE FROM chatlist_invites WHERE owner_user_id = $1`,
`DELETE FROM saved_dialog_pins WHERE user_id = $1`,
`DELETE FROM message_box_media WHERE owner_user_id = $1`,
`DELETE FROM private_media_category_counts WHERE owner_user_id = $1`,
`DELETE FROM message_boxes WHERE owner_user_id = $1`,
`DELETE FROM dialogs WHERE user_id = $1`,
`DELETE FROM dispatch_outbox WHERE target_user_id = $1`,
`DELETE FROM dispatch_outbox_user_heads WHERE target_user_id = $1`,
`DELETE FROM user_update_events WHERE user_id = $1`,
`DELETE FROM user_update_retention WHERE user_id = $1`,
`DELETE FROM user_update_watermarks WHERE user_id = $1`,
`DELETE FROM update_states WHERE user_id = $1`,
`DELETE FROM bootstrap_update_jobs WHERE user_id = $1`,
`DELETE FROM scheduled_messages WHERE owner_user_id = $1`,
`DELETE FROM quick_reply_messages WHERE owner_user_id = $1`,
`DELETE FROM quick_replies WHERE owner_user_id = $1`,
`DELETE FROM saved_music WHERE user_id = $1`,
`DELETE FROM user_sticker_collections WHERE owner_user_id = $1`,
`DELETE FROM user_sticker_sets WHERE owner_user_id = $1`,
`DELETE FROM user_recent_reactions WHERE user_id = $1`,
`DELETE FROM user_saved_reaction_tags WHERE user_id = $1`,
`DELETE FROM user_top_reactions WHERE user_id = $1`,
`DELETE FROM theme_user_installs WHERE user_id = $1`,
`DELETE FROM peer_translation_settings WHERE user_id = $1`,
`DELETE FROM ai_compose_tone_saves WHERE user_id = $1`,
`DELETE FROM ai_compose_tones WHERE owner_user_id = $1`,
`DELETE FROM business_automation_deliveries WHERE owner_user_id = $1 OR peer_user_id = $1`,
`DELETE FROM business_connected_bot_peer_states WHERE owner_user_id = $1 OR peer_user_id = $1`,
`DELETE FROM business_connected_bots WHERE owner_user_id = $1`,
`DELETE FROM business_chat_links WHERE owner_user_id = $1`,
`DELETE FROM user_business_profiles WHERE user_id = $1`,
`DELETE FROM attach_menu_user_states WHERE user_id = $1`,
`DELETE FROM bot_emoji_status_permissions WHERE user_id = $1`,
`DELETE FROM bot_user_permissions WHERE user_id = $1`,
`DELETE FROM login_code_message_deliveries WHERE user_id = $1`,
`DELETE FROM webview_custom_method_queries WHERE user_id = $1`,
`DELETE FROM webview_requested_buttons WHERE user_id = $1`,
`DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
`DELETE FROM story_views WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_exposures WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_read_states WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_hidden_peers WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM stories WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
`DELETE FROM group_call_schedule_subscribers WHERE user_id = $1`,
`DELETE FROM group_call_participants WHERE user_id = $1`,
`DELETE FROM group_call_invites WHERE inviter_user_id = $1 OR invitee_user_id = $1`,
`DELETE FROM channel_boost_slots WHERE user_id = $1`,
`DELETE FROM channel_invite_importers WHERE user_id = $1`,
`DELETE FROM channel_topic_read WHERE user_id = $1`,
`DELETE FROM channel_unread_mentions WHERE user_id = $1`,
`DELETE FROM channel_unread_mention_index WHERE user_id = $1`,
`DELETE FROM channel_dialogs WHERE user_id = $1`,
`DELETE FROM user_channel_member_index WHERE user_id = $1`,
`DELETE FROM account_deletion_notifications WHERE target_user_id = $1`,
`DELETE FROM uploaded_media_receipts WHERE owner_user_id = $1`,
`DELETE FROM upload_parts WHERE owner_user_id = $1`,
`DELETE FROM encrypted_files WHERE owner_user_id = $1`,
}
for _, statement := range statements {
if _, err := tx.Exec(ctx, statement, userID); err != nil {
return fmt.Errorf("purge deleted account private state (%s): %w", statement, err)
}
}
if _, err := tx.Exec(ctx, `
WITH changed AS (
UPDATE channel_members
SET status = 'left', left_at = $2, unread_mark = false, updated_at = $3
WHERE user_id = $1 AND status = 'active'
RETURNING channel_id, role
), counts AS (
SELECT channel_id, count(*) AS participants,
count(*) FILTER (WHERE role IN ('creator', 'admin')) AS admins
FROM changed GROUP BY channel_id
)
UPDATE channels c
SET participants_count = GREATEST(0, c.participants_count - counts.participants::int),
admins_count = GREATEST(0, c.admins_count - counts.admins::int),
updated_at = $3
FROM counts WHERE c.id = counts.channel_id`, userID, int(now.Unix()), now); err != nil {
return fmt.Errorf("leave deleted account channels: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE secret_chats SET state = 'discarded', history_deleted = true,
g_a = ''::bytea, g_b = ''::bytea, key_fingerprint = 0
WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil {
return fmt.Errorf("discard deleted account secret chats: %w", err)
}
return nil
}
func settleDeletedAccountFinancialState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
nowUnix := int(now.Unix())
rows, err := tx.Query(ctx, `
SELECT id, buyer_user_id, currency, amount
FROM star_gift_offers
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND status = 'pending'
ORDER BY id FOR UPDATE`, userID)
if err != nil {
return fmt.Errorf("lock deleted account gift offers: %w", err)
}
type offer struct {
id, buyer, amount int64
currency string
}
offers := make([]offer, 0)
for rows.Next() {
var o offer
if err := rows.Scan(&o.id, &o.buyer, &o.currency, &o.amount); err != nil {
rows.Close()
return fmt.Errorf("scan deleted account gift offer: %w", err)
}
offers = append(offers, o)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
for _, o := range offers {
var balance int64
if o.currency == "XTR" {
if err := tx.QueryRow(ctx, `
INSERT INTO stars_balances (user_id, balance) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()
RETURNING balance`, o.buyer, o.amount).Scan(&balance); err != nil {
return fmt.Errorf("refund deleted account stars offer: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
(user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, 'user', $2, $3, 'gift_offer_refund_account_deleted', 'Gift offer refunded', '', $4)`, o.buyer, userID, o.amount, nowUnix); err != nil {
return fmt.Errorf("record deleted account stars refund: %w", err)
}
} else {
if err := tx.QueryRow(ctx, `
INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET balance_nanoton = ton_balances.balance_nanoton + EXCLUDED.balance_nanoton, updated_at = now()
RETURNING balance_nanoton`, o.buyer, o.amount).Scan(&balance); err != nil {
return fmt.Errorf("refund deleted account TON offer: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
(user_id, amount_nanoton, reason, peer_type, peer_id, date)
VALUES ($1, $2, 'gift_offer_refund_account_deleted', 'user', $3, $4)`, o.buyer, o.amount, userID, nowUnix); err != nil {
return fmt.Errorf("record deleted account TON refund: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
SET status = 'cancelled', resolved_at = $2, balance_after = $3
WHERE id = $1 AND status = 'pending'`, o.id, nowUnix, balance); err != nil {
return fmt.Errorf("cancel deleted account gift offer: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
SET status = 'cancelled', resolved_at = $2, balance_after = 0
WHERE buyer_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
return fmt.Errorf("cancel deleted buyer gift offers: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests
SET status = 'failed', completed_at = $2 WHERE owner_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
return fmt.Errorf("fail deleted account withdrawals: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active = false, version = version + 1
WHERE bidder_user_id = $1 AND active = true`, userID); err != nil {
return fmt.Errorf("deactivate deleted account auction bids: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts
SET burned = true, owner_name = '', updated_at = $2
WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID, now); err != nil {
return fmt.Errorf("burn deleted account unique gifts: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts
SET lifecycle_status = 'burned', unsaved = true, pinned_order = 0
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NOT NULL`, userID); err != nil {
return fmt.Errorf("burn deleted account saved gifts: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM peer_star_gifts
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NULL`, userID); err != nil {
return fmt.Errorf("delete deleted account regular gifts: %w", err)
}
var stars int64
if err := tx.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&stars); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lock deleted account stars balance: %w", err)
}
if stars != 0 {
if _, err := tx.Exec(ctx, `UPDATE stars_balances SET balance = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
return fmt.Errorf("zero deleted account stars: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
(user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, 'user', $1, $2, 'account_deleted', 'Account deleted', '', $3)`, userID, -stars, nowUnix); err != nil {
return fmt.Errorf("record deleted account stars clearing: %w", err)
}
}
var ton int64
if err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&ton); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lock deleted account TON balance: %w", err)
}
if ton != 0 {
if _, err := tx.Exec(ctx, `UPDATE ton_balances SET balance_nanoton = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
return fmt.Errorf("zero deleted account TON: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
(user_id, amount_nanoton, reason, date) VALUES ($1, $2, 'account_deleted', $3)`, userID, -ton, nowUnix); err != nil {
return fmt.Errorf("record deleted account TON clearing: %w", err)
}
}
return nil
}

View file

@ -0,0 +1,289 @@
package postgres
import (
"context"
"crypto/rand"
"crypto/sha256"
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
nonce := time.Now().UnixNano()
users := NewUserStore(pool)
deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me")
peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID})
})
authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1)
authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2)
if _, err := pool.Exec(ctx, `INSERT INTO contacts
(user_id, contact_user_id, contact_phone, contact_first_name, contact_last_name)
VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err != nil {
t.Fatalf("insert reverse contact: %v", err)
}
if _, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: deleted.ID, RecipientUserID: peer.ID, RandomID: nonce, Message: "keep shared history",
}); err != nil {
t.Fatalf("send shared message: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)`, deleted.ID); err != nil {
t.Fatalf("insert account settings: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances (user_id, balance) VALUES ($1, 50)`, deleted.ID); err != nil {
t.Fatalf("insert stars balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil {
t.Fatalf("insert TON balance: %v", err)
}
lifecycle := NewAccountLifecycleStore(pool)
now := time.Now().UTC().Truncate(time.Second)
digestOne := sha256.Sum256([]byte("confirm-one"))
pending, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
UserID: deleted.ID, RequesterAuthKeyID: authOne, Reason: "Forgot password",
ConfirmHashDigest: digestOne, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-one",
RequestedAt: now, ExecuteAt: now.Add(7 * 24 * time.Hour),
})
if err != nil || !created || pending.UserID != deleted.ID {
t.Fatalf("schedule deletion = %+v created=%v err=%v", pending, created, err)
}
if got, found, err := lifecycle.PendingAccountDeletionByHash(ctx, deleted.ID, digestOne); err != nil || !found || got.ID != pending.ID {
t.Fatalf("pending deletion by hash = %+v found=%v err=%v", got, found, err)
}
revoked, err := lifecycle.CancelAccountDeletion(ctx, deleted.ID, digestOne, now.Add(time.Minute))
if err != nil || len(revoked) != 1 || revoked[0].AuthKeyID != authOne {
t.Fatalf("cancel deletion revoked=%+v err=%v", revoked, err)
}
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authOne); err != nil || found {
t.Fatalf("requester auth key after cancel found=%v err=%v, want revoked", found, err)
}
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err)
}
result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute))
if err != nil {
t.Fatalf("execute account deletion: %v", err)
}
if !result.Changed || !result.User.Deleted || result.User.Phone != "" || result.User.FirstName != "" || len(result.RevokedAuthorizations) != 1 {
t.Fatalf("deletion result = %+v", result)
}
if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found {
t.Fatalf("released phone found=%v err=%v", found, err)
}
if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" {
t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err)
}
if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil {
t.Fatal("deleted account profile mutation unexpectedly succeeded")
}
history, err := NewMessageStore(pool).ListByUser(ctx, peer.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
Limit: 10,
})
if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID {
t.Fatalf("peer history after deletion = %+v err=%v", history, err)
}
var peerBoxes, settings, contacts, notifications int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_settings WHERE user_id = $1`, deleted.ID).Scan(&settings); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, deleted.ID).Scan(&contacts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(&notifications); err != nil {
t.Fatal(err)
}
if peerBoxes != 1 || settings != 0 || contacts != 0 || notifications != 1 {
t.Fatalf("post-delete state peerBoxes=%d settings=%d contacts=%d notifications=%d", peerBoxes, settings, contacts, notifications)
}
var stars, ton, starClear, tonClear int64
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1`, deleted.ID).Scan(&ton); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount), 0) FROM stars_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&starClear); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil {
t.Fatal(err)
}
if stars != 0 || ton != 0 || starClear != -50 || tonClear != -100 {
t.Fatalf("financial clearing stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
}
}
func TestAccountLifecycleDueSourcesAndTTLWatermarkPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
nonce := time.Now().UnixNano()
users := NewUserStore(pool)
ttlUser := createTestUser(t, ctx, users, fmt.Sprintf("15671%d", nonce), "TTL", "User")
freezeUser := createTestUser(t, ctx, users, fmt.Sprintf("15672%d", nonce), "Frozen", "User")
pendingUser := createTestUser(t, ctx, users, fmt.Sprintf("15673%d", nonce), "Pending", "User")
ids := []int64{ttlUser.ID, freezeUser.ID, pendingUser.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, ids)
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, ids)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, ids)
})
now := time.Now().UTC().Truncate(time.Second)
if _, err := pool.Exec(ctx, `UPDATE users SET account_delete_at = $2 WHERE id = $1`, ttlUser.ID, now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_restrictions
(user_id, frozen, reason, actor, command_id, frozen_since, frozen_until, appeal_url)
VALUES ($1, true, 'abuse', 'test', 'freeze-test', $2, $3, 'https://example.test/appeal')`, freezeUser.ID, now.Add(-time.Hour), now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("due-pending"))
if _, err := pool.Exec(ctx, `INSERT INTO account_deletion_requests
(user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at)
VALUES ($1, 123, 'forgot', $2, $3, $4)`, pendingUser.ID, digest[:], now.Add(-8*24*time.Hour), now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
candidates, err := NewAccountLifecycleStore(pool).DueAccountDeletions(ctx, now, 10)
if err != nil {
t.Fatal(err)
}
sources := make(map[int64]domain.AccountDeletionSource, len(candidates))
for _, candidate := range candidates {
sources[candidate.UserID] = candidate.Source
}
if sources[ttlUser.ID] != domain.AccountDeletionAccountTTL || sources[freezeUser.ID] != domain.AccountDeletionFreezeExpiry || sources[pendingUser.ID] != domain.AccountDeletionPasswordResetExpiry {
t.Fatalf("due sources = %+v", sources)
}
seen := now.Add(time.Hour)
if err := users.UpdateLastSeen(ctx, ttlUser.ID, int(seen.Unix())); err != nil {
t.Fatal(err)
}
lifecycle := NewAccountLifecycleStore(pool)
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, ttlUser.ID, domain.AccountDeletionAccountTTL, "", now); err != nil || stale.Changed {
t.Fatalf("stale TTL candidate changed=%v err=%v", stale.Changed, err)
}
if _, err := pool.Exec(ctx, `UPDATE account_restrictions SET frozen_until = $2, updated_at = $3 WHERE user_id = $1`, freezeUser.ID, now.Add(24*time.Hour), now); err != nil {
t.Fatal(err)
}
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, freezeUser.ID, domain.AccountDeletionFreezeExpiry, "", now); err != nil || stale.Changed {
t.Fatalf("extended freeze candidate changed=%v err=%v", stale.Changed, err)
}
if _, err := pool.Exec(ctx, `UPDATE account_deletion_requests SET state = 'cancelled', completed_at = $2, updated_at = $2 WHERE user_id = $1`, pendingUser.ID, now); err != nil {
t.Fatal(err)
}
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, pendingUser.ID, domain.AccountDeletionPasswordResetExpiry, "", now); err != nil || stale.Changed {
t.Fatalf("cancelled pending candidate changed=%v err=%v", stale.Changed, err)
}
var deadline time.Time
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
t.Fatal(err)
}
if want := seen.Add(365 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
t.Fatalf("TTL watermark deadline=%v want=%v", deadline, want)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)
ON CONFLICT (user_id) DO UPDATE SET account_ttl_days = EXCLUDED.account_ttl_days`, ttlUser.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
t.Fatal(err)
}
if want := seen.Add(30 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
t.Fatalf("custom TTL deadline=%v want=%v", deadline, want)
}
}
func TestAccountPasswordChangedAtIgnoresSRPChallengeRotationPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createTestUser(t, ctx, NewUserStore(pool), fmt.Sprintf("15771%d", time.Now().UnixNano()), "Password", "Clock")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, user.ID) })
if _, err := pool.Exec(ctx, `INSERT INTO account_passwords
(user_id, has_password, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_verifier, srp_id, srp_b)
VALUES ($1, true, '\x01', '\x02', 3, '\x03', '\x04', 10, '\x05')`, user.ID); err != nil {
t.Fatal(err)
}
var initial, afterChallenge, afterPassword time.Time
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&initial); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_id = 11, srp_b = '\x06', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterChallenge); err != nil {
t.Fatal(err)
}
if !afterChallenge.Equal(initial) {
t.Fatalf("SRP challenge rotation changed password clock: initial=%v after=%v", initial, afterChallenge)
}
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier = '\x07', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterPassword); err != nil {
t.Fatal(err)
}
if !afterPassword.After(afterChallenge) {
t.Fatalf("password verifier change did not advance clock: before=%v after=%v", afterChallenge, afterPassword)
}
}
func TestTruncateAccountDeletionReasonUTF8(t *testing.T) {
got := truncateUTF8Bytes(strings.Repeat("界", 400), 1024)
if !utf8.ValidString(got) || len(got) > 1024 {
t.Fatalf("truncateUTF8Bytes returned invalid result: valid=%v bytes=%d", utf8.ValidString(got), len(got))
}
if got == "" {
t.Fatal("truncateUTF8Bytes unexpectedly removed the whole reason")
}
}
func saveLifecycleTestAuthorization(t *testing.T, ctx context.Context, db *pgxpool.Pool, userID int64, marker byte) [8]byte {
t.Helper()
var id [8]byte
var value [256]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatal(err)
}
id[0] = marker
if _, err := rand.Read(value[:]); err != nil {
t.Fatal(err)
}
if err := NewAuthKeyStore(db).Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil {
t.Fatalf("save lifecycle auth key: %v", err)
}
if err := NewAuthorizationStore(db).Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID, Hash: int64(marker)}); err != nil {
t.Fatalf("bind lifecycle authorization: %v", err)
}
return id
}

View file

@ -46,7 +46,7 @@ func (s *MessageStore) DefaultHistoryTTL(ctx context.Context, userID int64) (int
return 0, nil return 0, nil
} }
var period int var period int
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1`, userID).Scan(&period) err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1 AND deleted_at IS NULL`, userID).Scan(&period)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return 0, nil return 0, nil
} }
@ -70,7 +70,7 @@ func (s *MessageStore) SetDefaultHistoryTTL(ctx context.Context, userID int64, p
UPDATE users UPDATE users
SET default_history_ttl_period = $2, SET default_history_ttl_period = $2,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1 AND deleted_at IS NULL
`, userID, period) `, userID, period)
if err != nil { if err != nil {
return fmt.Errorf("set default history ttl: %w", err) return fmt.Errorf("set default history ttl: %w", err)

View file

@ -8,16 +8,16 @@ WHERE id = ANY(sqlc.arg(ids)::bigint[])
ORDER BY id; ORDER BY id;
-- name: GetUserByPhone :one -- name: GetUserByPhone :one
SELECT * FROM users WHERE phone = $1; SELECT * FROM users WHERE phone = $1 AND deleted_at IS NULL;
-- name: GetUsersByPhones :many -- name: GetUsersByPhones :many
SELECT * SELECT *
FROM users FROM users
WHERE phone = ANY(sqlc.arg(phones)::text[]) WHERE phone = ANY(sqlc.arg(phones)::text[]) AND deleted_at IS NULL
ORDER BY id; ORDER BY id;
-- name: GetUserByUsername :one -- name: GetUserByUsername :one
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> ''; SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL;
-- name: SearchUsers :many -- name: SearchUsers :many
WITH matched AS ( WITH matched AS (
@ -57,6 +57,7 @@ WITH matched AS (
FROM users u FROM users u
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
WHERE u.id <> sqlc.arg(current_user_id)::bigint WHERE u.id <> sqlc.arg(current_user_id)::bigint
AND u.deleted_at IS NULL
AND sqlc.arg(query_lower)::text <> '' AND sqlc.arg(query_lower)::text <> ''
AND ( AND (
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%') (sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
@ -107,14 +108,14 @@ RETURNING *;
UPDATE users UPDATE users
SET username = $2, SET username = $2,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1 AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: UpdateUserLastSeen :exec -- name: UpdateUserLastSeen :exec
UPDATE users UPDATE users
SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint), SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint),
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint; WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL;
-- name: UpdateUserProfile :one -- name: UpdateUserProfile :one
UPDATE users UPDATE users
@ -122,28 +123,28 @@ SET first_name = $2,
last_name = $3, last_name = $3,
about = $4, about = $4,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1 AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: UpdateUserPhone :one -- name: UpdateUserPhone :one
UPDATE users UPDATE users
SET phone = sqlc.arg(phone)::text, SET phone = sqlc.arg(phone)::text,
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: SetUserPremiumUntil :one -- name: SetUserPremiumUntil :one
UPDATE users UPDATE users
SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz, SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz,
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: SetUserVerified :one -- name: SetUserVerified :one
UPDATE users UPDATE users
SET verified = sqlc.arg(verified)::boolean, SET verified = sqlc.arg(verified)::boolean,
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: SweepExpiredPremium :many -- name: SweepExpiredPremium :many
@ -153,6 +154,7 @@ SET premium_expires_at = NULL,
WHERE id IN ( WHERE id IN (
SELECT id FROM users SELECT id FROM users
WHERE premium_expires_at IS NOT NULL WHERE premium_expires_at IS NOT NULL
AND deleted_at IS NULL
AND premium_expires_at <= sqlc.arg(now)::timestamptz AND premium_expires_at <= sqlc.arg(now)::timestamptz
ORDER BY premium_expires_at ORDER BY premium_expires_at
LIMIT sqlc.arg(limit_count)::int LIMIT sqlc.arg(limit_count)::int
@ -164,7 +166,7 @@ UPDATE users
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint, SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
emoji_status_until = sqlc.arg(emoji_status_until)::bigint, emoji_status_until = sqlc.arg(emoji_status_until)::bigint,
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: UpdateUserBirthday :one -- name: UpdateUserBirthday :one
@ -173,14 +175,14 @@ SET birthday_day = sqlc.arg(birthday_day)::int,
birthday_month = sqlc.arg(birthday_month)::int, birthday_month = sqlc.arg(birthday_month)::int,
birthday_year = sqlc.arg(birthday_year)::int, birthday_year = sqlc.arg(birthday_year)::int,
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: UpdateUserPersonalChannel :one -- name: UpdateUserPersonalChannel :one
UPDATE users UPDATE users
SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint, SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint,
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: UpdateUserColor :one -- name: UpdateUserColor :one
@ -189,7 +191,7 @@ SET color_set = sqlc.arg(color_set)::boolean,
color = sqlc.arg(color)::int, color = sqlc.arg(color)::int,
color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint, color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;
-- name: UpdateUserProfileColor :one -- name: UpdateUserProfileColor :one
@ -198,5 +200,5 @@ SET profile_color_set = sqlc.arg(color_set)::boolean,
profile_color = sqlc.arg(color)::int, profile_color = sqlc.arg(color)::int,
profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint, profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
updated_at = now() updated_at = now()
WHERE id = sqlc.arg(id)::bigint WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *; RETURNING *;

View file

@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
const insertBotUser = `-- name: InsertBotUser :one const insertBotUser = `-- name: InsertBotUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
VALUES ($1, '', $2, '', $3, '', TRUE, 1) VALUES ($1, '', $2, '', $3, '', TRUE, 1)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type InsertBotUserParams struct { type InsertBotUserParams struct {
@ -209,6 +209,10 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }

View file

@ -8,6 +8,32 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
type AccountDeletionNotification struct {
ID int64
TargetUserID int64
DeletedUserID int64
Status string
Attempts int32
NextAttemptAt pgtype.Timestamptz
LeaseUntil pgtype.Timestamptz
LastError string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type AccountDeletionRequest struct {
ID int64
UserID int64
RequesterAuthKeyID int64
State string
Reason string
ConfirmHashDigest []byte
RequestedAt pgtype.Timestamptz
ExecuteAt pgtype.Timestamptz
CompletedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type AccountPassword struct { type AccountPassword struct {
UserID int64 UserID int64
HasRecovery bool HasRecovery bool
@ -30,6 +56,7 @@ type AccountPassword struct {
RecoveryCode string RecoveryCode string
RecoveryCodeExpiresAt pgtype.Timestamptz RecoveryCodeExpiresAt pgtype.Timestamptz
LoginEmail string LoginEmail string
PasswordChangedAt pgtype.Timestamptz
} }
type AccountPrivacyRule struct { type AccountPrivacyRule struct {
@ -694,6 +721,42 @@ type ChannelMessageViewer struct {
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
} }
type ChannelStarsBalance struct {
ChannelID int64
Balance int64
UpdatedAt pgtype.Timestamptz
}
type ChannelStarsTransaction struct {
ID int64
ChannelID int64
ActorUserID int64
Amount int64
Reason string
PeerType string
PeerID int64
GiftID *int64
Date int32
}
type ChannelTonBalance struct {
ChannelID int64
BalanceNanoton int64
UpdatedAt pgtype.Timestamptz
}
type ChannelTonTransaction struct {
ID int64
ChannelID int64
ActorUserID int64
AmountNanoton int64
Reason string
PeerType string
PeerID int64
GiftID *int64
Date int32
}
type ChannelTopicRead struct { type ChannelTopicRead struct {
ChannelID int64 ChannelID int64
UserID int64 UserID int64
@ -1156,19 +1219,33 @@ type PasskeyCredential struct {
} }
type PeerStarGift struct { type PeerStarGift struct {
ID int64 ID int64
OwnerPeerID int64 OwnerPeerID int64
FromUserID int64 FromUserID int64
GiftID int64 GiftID int64
MsgID int32 MsgID int32
GiftDate int32 GiftDate int32
NameHidden bool NameHidden bool
Unsaved bool Unsaved bool
Converted bool Converted bool
ConvertStars int64 ConvertStars int64
Message string Message string
OwnerPeerType string OwnerPeerType string
SavedID int64 SavedID int64
CatalogRevisionID int64
UniqueGiftID *int64
UpgradeMsgID int32
PinnedOrder int32
PrepaidUpgradeStars int64
LifecycleStatus string
TransferStars int64
PrepaidUpgradeHash string
GiftNum int32
CanExportAt int32
CanTransferAt int32
CanResellAt int32
DropOriginalDetailsStars int64
CanCraftAt int32
} }
type PeerTranslationSetting struct { type PeerTranslationSetting struct {
@ -1417,6 +1494,382 @@ type SeedState struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type StarGiftAuction struct {
GiftID int64
Slug string
Version int32
StartDate int32
EndDate int32
RoundDuration int32
GiftsPerRound int32
TotalRounds int32
CurrentRound int32
NextRoundAt int32
LastGiftNum int32
GiftsLeft int32
MinBidAmount int64
Status string
UpdatedAt pgtype.Timestamptz
}
type StarGiftAuctionAcquired struct {
ID int64
GiftID int64
BidderUserID int64
RecipientPeerType string
RecipientPeerID int64
SavedGiftID *int64
BidAmount int64
Round int32
Pos int32
GiftNum *int32
AcquiredAt int32
HideName bool
Message string
}
type StarGiftAuctionBid struct {
GiftID int64
BidderUserID int64
RecipientPeerType string
RecipientPeerID int64
Amount int64
BidDate int32
HideName bool
Message string
Returned bool
AcquiredCount int32
Active bool
Version int64
}
type StarGiftAuctionBidPayment struct {
UserID int64
FormID int64
GiftID int64
BidAmount int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftCatalog struct {
GiftID int64
ActiveRevisionID int64
Enabled bool
SortOrder int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
CollectibleRevisionID *int64
AvailabilityRemains int32
AvailabilityResale int64
ResellMinStars int64
FirstSaleDate int32
LastSaleDate int32
}
type StarGiftCatalogRevision struct {
ID int64
GiftID int64
Revision int32
Title string
Stars int64
ConvertStars int64
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
CreatedBy string
CommandID string
CreatedAt pgtype.Timestamptz
OfficialGiftID *int64
SourceManifestSha256 []byte
OfficialSource []byte
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
AvailabilityTotal int32
ReleasedByPeerType *string
ReleasedByPeerID *int64
PerUserTotal int32
LockedUntilDate int32
AuctionSlug string
GiftsPerRound int32
AuctionStartDate int32
UpgradeVariants int32
BackgroundCenterColor *int32
BackgroundEdgeColor *int32
BackgroundTextColor *int32
}
type StarGiftCollectibleBackdrop struct {
ID int64
CollectibleRevisionID int64
Name string
BackdropID int32
CenterColor int32
EdgeColor int32
PatternColor int32
TextColor int32
RarityPermille *int32
SortOrder int32
RarityKind string
}
type StarGiftCollectibleModel struct {
ID int64
CollectibleRevisionID int64
Name string
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
RarityPermille *int32
SortOrder int32
RarityKind string
Crafted bool
OfficialDocumentID *int64
}
type StarGiftCollectiblePattern struct {
ID int64
CollectibleRevisionID int64
Name string
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
RarityPermille *int32
SortOrder int32
RarityKind string
OfficialDocumentID *int64
}
type StarGiftCollectibleRevision struct {
ID int64
GiftID int64
Revision int32
UpgradeStars int64
SupplyTotal int32
Issued int32
SlugPrefix string
Status string
CreatedBy string
CommandID string
CreatedAt pgtype.Timestamptz
PublishedAt pgtype.Timestamptz
OfficialGiftID *int64
SourceManifestSha256 []byte
}
type StarGiftCollection struct {
CollectionID int32
OwnerPeerType string
OwnerPeerID int64
Title string
SortOrder int32
Hash int64
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type StarGiftCollectionItem struct {
CollectionID int32
SavedGiftID int64
SortOrder int32
CreatedAt pgtype.Timestamptz
}
type StarGiftConversion struct {
SavedGiftID int64
ActorUserID int64
OwnerPeerType string
OwnerPeerID int64
Amount int64
BalanceAfter int64
ConvertedAt int32
}
type StarGiftCraftCommand struct {
UserID int64
CommandKey string
InputUniqueGiftIds []int64
GiftID int64
Success bool
ResultUniqueGiftID *int64
ChancePermille int32
CreatedAt int32
SourceEditPts []int32
}
type StarGiftDropDetailsCommand struct {
UserID int64
CommandKey string
SavedGiftID int64
UniqueGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftListing struct {
UniqueGiftID int64
SellerPeerType string
SellerPeerID int64
Currency string
Amount int64
ListedAt int32
UpdatedAt int32
Version int64
}
type StarGiftNotificationSetting struct {
UserID int64
ChannelID int64
Enabled bool
UpdatedAt pgtype.Timestamptz
}
type StarGiftOffer struct {
ID int64
BuyerUserID int64
OwnerPeerType string
OwnerPeerID int64
UniqueGiftID int64
Currency string
Amount int64
RandomID int64
OfferMsgID int32
BuyerMsgID int32
Status string
CreatedAt int32
ExpiresAt int32
ResolvedAt int32
BalanceAfter int64
ResolutionNotified bool
}
type StarGiftPrepaidUpgradeCommand struct {
PayerUserID int64
CommandKey string
SavedGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftPurchaseCommand struct {
BuyerUserID int64
CommandKey string
GiftID int64
RecipientPeerType string
RecipientPeerID int64
SavedGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftPurchaseForm struct {
BuyerUserID int64
FormID int64
GiftID int64
RevisionID int64
RecipientPeerType string
RecipientPeerID int64
IncludeUpgrade bool
HideName bool
Message string
ChargeStars int64
IssuedAt int32
ExpiresAt int32
}
type StarGiftSale struct {
ID int64
UniqueGiftID int64
SellerPeerType string
SellerPeerID int64
BuyerPeerType string
BuyerPeerID int64
Currency string
Amount int64
CommissionAmount int64
SoldAt int32
CommandKey string
}
type StarGiftTransferCommand struct {
ActorUserID int64
CommandKey string
UniqueGiftID int64
FromPeerType string
FromPeerID int64
ToPeerType string
ToPeerID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftUpgradeCommand struct {
UserID int64
CommandKey string
SourceSavedGiftID int64
FormID int64
UniqueGiftID int64
BalanceAfter int64
CreatedAt pgtype.Timestamptz
ChargeStars int64
RequirePrepaid bool
KeepOriginalDetails bool
SourceEditPts int32
}
type StarGiftUserPurchase struct {
UserID int64
GiftID int64
PurchasedCount int32
UpdatedAt pgtype.Timestamptz
}
type StarGiftWithdrawalRequest struct {
ID int64
UniqueGiftID int64
OwnerUserID int64
Provider string
ProviderRequestID string
Url string
Status string
CreatedAt int32
ExpiresAt int32
CompletedAt int32
}
type StarsBalance struct { type StarsBalance struct {
UserID int64 UserID int64
Balance int64 Balance int64
@ -1564,6 +2017,66 @@ type ThemeUserInstall struct {
InstalledAt pgtype.Timestamptz InstalledAt pgtype.Timestamptz
} }
type TonBalance struct {
UserID int64
BalanceNanoton int64
Granted bool
UpdatedAt pgtype.Timestamptz
}
type TonTransaction struct {
ID int64
UserID int64
AmountNanoton int64
Reason string
PeerType *string
PeerID *int64
GiftID *int64
Date int32
}
type UniqueStarGift struct {
ID int64
GiftID int64
CollectibleRevisionID int64
SourceSavedGiftID int64
Title string
Slug string
Num int32
OwnerPeerType *string
OwnerPeerID *int64
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
KeepOriginalDetails bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
RequirePremium bool
ResaleTonOnly bool
ThemeAvailable bool
Burned bool
Crafted bool
OriginalOwnerPeerType string
OriginalOwnerPeerID int64
OwnerName string
OwnerAddress string
GiftAddress string
ReleasedByPeerType *string
ReleasedByPeerID *int64
ValueAmount int64
ValueCurrency string
ValueUsdAmount int64
ThemePeerType *string
ThemePeerID *int64
HostPeerType *string
HostPeerID *int64
OfferMinStars int32
CraftChancePermille int32
LastSaleDate int32
LastSaleCurrency string
LastSaleAmount int64
}
type UpdateState struct { type UpdateState struct {
AuthKeyID int64 AuthKeyID int64
Pts int32 Pts int32
@ -1627,6 +2140,10 @@ type User struct {
BirthdayMonth int32 BirthdayMonth int32
BirthdayYear int32 BirthdayYear int32
PersonalChannelID int64 PersonalChannelID int64
DeletedAt pgtype.Timestamptz
DeletionSource string
DeletionReason string
AccountDeleteAt pgtype.Timestamptz
} }
type UserBusinessProfile struct { type UserBusinessProfile struct {

View file

@ -14,7 +14,7 @@ import (
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type CreateUserParams struct { type CreateUserParams struct {
@ -68,12 +68,16 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE id = $1 SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE id = $1
` `
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@ -109,12 +113,16 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
const getUserByPhone = `-- name: GetUserByPhone :one const getUserByPhone = `-- name: GetUserByPhone :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE phone = $1 SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE phone = $1 AND deleted_at IS NULL
` `
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
@ -150,12 +158,16 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
const getUserByUsername = `-- name: GetUserByUsername :one const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE lower(username) = lower($1) AND username <> '' SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
` `
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) { func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
@ -191,12 +203,16 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
const getUsersByIDs = `-- name: GetUsersByIDs :many const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
FROM users FROM users
WHERE id = ANY($1::bigint[]) WHERE id = ANY($1::bigint[])
ORDER BY id ORDER BY id
@ -241,6 +257,10 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -253,9 +273,9 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
} }
const getUsersByPhones = `-- name: GetUsersByPhones :many const getUsersByPhones = `-- name: GetUsersByPhones :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
FROM users FROM users
WHERE phone = ANY($1::text[]) WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
ORDER BY id ORDER BY id
` `
@ -298,6 +318,10 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -347,6 +371,7 @@ WITH matched AS (
FROM users u FROM users u
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
WHERE u.id <> $4::bigint WHERE u.id <> $4::bigint
AND u.deleted_at IS NULL
AND $3::text <> '' AND $3::text <> ''
AND ( AND (
($2::text <> '' AND u.phone LIKE $2::text || '%') ($2::text <> '' AND u.phone LIKE $2::text || '%')
@ -479,8 +504,8 @@ const setUserPremiumUntil = `-- name: SetUserPremiumUntil :one
UPDATE users UPDATE users
SET premium_expires_at = $1::timestamptz, SET premium_expires_at = $1::timestamptz,
updated_at = now() updated_at = now()
WHERE id = $2::bigint WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type SetUserPremiumUntilParams struct { type SetUserPremiumUntilParams struct {
@ -521,6 +546,10 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -529,8 +558,8 @@ const setUserVerified = `-- name: SetUserVerified :one
UPDATE users UPDATE users
SET verified = $1::boolean, SET verified = $1::boolean,
updated_at = now() updated_at = now()
WHERE id = $2::bigint WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type SetUserVerifiedParams struct { type SetUserVerifiedParams struct {
@ -571,6 +600,10 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -582,11 +615,12 @@ SET premium_expires_at = NULL,
WHERE id IN ( WHERE id IN (
SELECT id FROM users SELECT id FROM users
WHERE premium_expires_at IS NOT NULL WHERE premium_expires_at IS NOT NULL
AND deleted_at IS NULL
AND premium_expires_at <= $1::timestamptz AND premium_expires_at <= $1::timestamptz
ORDER BY premium_expires_at ORDER BY premium_expires_at
LIMIT $2::int LIMIT $2::int
) )
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type SweepExpiredPremiumParams struct { type SweepExpiredPremiumParams struct {
@ -633,6 +667,10 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -650,8 +688,8 @@ SET birthday_day = $1::int,
birthday_month = $2::int, birthday_month = $2::int,
birthday_year = $3::int, birthday_year = $3::int,
updated_at = now() updated_at = now()
WHERE id = $4::bigint WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type UpdateUserBirthdayParams struct { type UpdateUserBirthdayParams struct {
@ -699,6 +737,10 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -709,8 +751,8 @@ SET color_set = $1::boolean,
color = $2::int, color = $2::int,
color_background_emoji_id = $3::bigint, color_background_emoji_id = $3::bigint,
updated_at = now() updated_at = now()
WHERE id = $4::bigint WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type UpdateUserColorParams struct { type UpdateUserColorParams struct {
@ -758,6 +800,10 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -767,8 +813,8 @@ UPDATE users
SET emoji_status_document_id = $1::bigint, SET emoji_status_document_id = $1::bigint,
emoji_status_until = $2::bigint, emoji_status_until = $2::bigint,
updated_at = now() updated_at = now()
WHERE id = $3::bigint WHERE id = $3::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type UpdateUserEmojiStatusParams struct { type UpdateUserEmojiStatusParams struct {
@ -810,6 +856,10 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -818,7 +868,7 @@ const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec
UPDATE users UPDATE users
SET last_seen_at = GREATEST(last_seen_at, $1::bigint), SET last_seen_at = GREATEST(last_seen_at, $1::bigint),
updated_at = now() updated_at = now()
WHERE id = $2::bigint WHERE id = $2::bigint AND deleted_at IS NULL
` `
type UpdateUserLastSeenParams struct { type UpdateUserLastSeenParams struct {
@ -835,8 +885,8 @@ const updateUserPersonalChannel = `-- name: UpdateUserPersonalChannel :one
UPDATE users UPDATE users
SET personal_channel_id = $1::bigint, SET personal_channel_id = $1::bigint,
updated_at = now() updated_at = now()
WHERE id = $2::bigint WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type UpdateUserPersonalChannelParams struct { type UpdateUserPersonalChannelParams struct {
@ -877,6 +927,10 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -885,8 +939,8 @@ const updateUserPhone = `-- name: UpdateUserPhone :one
UPDATE users UPDATE users
SET phone = $1::text, SET phone = $1::text,
updated_at = now() updated_at = now()
WHERE id = $2::bigint WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type UpdateUserPhoneParams struct { type UpdateUserPhoneParams struct {
@ -927,6 +981,10 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -937,8 +995,8 @@ SET first_name = $2,
last_name = $3, last_name = $3,
about = $4, about = $4,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type UpdateUserProfileParams struct { type UpdateUserProfileParams struct {
@ -986,6 +1044,10 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -996,8 +1058,8 @@ SET profile_color_set = $1::boolean,
profile_color = $2::int, profile_color = $2::int,
profile_color_background_emoji_id = $3::bigint, profile_color_background_emoji_id = $3::bigint,
updated_at = now() updated_at = now()
WHERE id = $4::bigint WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type UpdateUserProfileColorParams struct { type UpdateUserProfileColorParams struct {
@ -1045,6 +1107,10 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }
@ -1053,8 +1119,8 @@ const updateUserUsername = `-- name: UpdateUserUsername :one
UPDATE users UPDATE users
SET username = $2, SET username = $2,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
` `
type UpdateUserUsernameParams struct { type UpdateUserUsernameParams struct {
@ -1095,6 +1161,10 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.BirthdayMonth, &i.BirthdayMonth,
&i.BirthdayYear, &i.BirthdayYear,
&i.PersonalChannelID, &i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
) )
return i, err return i, err
} }

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err) t.Fatalf("migrate star gift lifecycle schema: %v", err)
} }
if status.Dirty || status.Empty || status.Version != 106 { if status.Dirty || status.Empty || status.Version != 107 {
t.Fatalf("migration status = %+v, want clean version 106", status) t.Fatalf("migration status = %+v, want clean version 107", status)
} }
} }

View file

@ -200,7 +200,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s
}() }()
qtx := s.q.WithTx(tx) qtx := s.q.WithTx(tx)
var lockedUserID int64 var lockedUserID int64
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedUserID); err != nil { if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUsernameNotOccupied return domain.User{}, domain.ErrUsernameNotOccupied
} }
@ -444,7 +444,7 @@ func escapeLike(s string) string {
} }
func userFromModel(r sqlcgen.User) domain.User { func userFromModel(r sqlcgen.User) domain.User {
return domain.User{ u := domain.User{
ID: r.ID, ID: r.ID,
AccessHash: r.AccessHash, AccessHash: r.AccessHash,
Phone: r.Phone, Phone: r.Phone,
@ -465,7 +465,17 @@ func userFromModel(r sqlcgen.User) domain.User {
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
LastSeenAt: int(r.LastSeenAt), LastSeenAt: int(r.LastSeenAt),
Deleted: r.DeletedAt.Valid,
DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
DeletionReason: r.DeletionReason,
CreatedAt: r.CreatedAt.Time,
AccountDeleteAt: r.AccountDeleteAt.Time,
} }
if r.DeletedAt.Valid {
u.DeletedAt = r.DeletedAt.Time.Unix()
return u.DeletedTombstone()
}
return u
} }
func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor { func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {