feat(server/auth): switch user identity to phone with email optional

Phone becomes the required, unique login identifier; email and name turn
optional. Login now accepts either via a single identifier field, refresh
re-checks tenant membership, and the dev seed provides phone numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:01:36 +08:00
parent a6b17bae62
commit ee21f512a9
11 changed files with 225 additions and 40 deletions
+6 -6
View File
@@ -326,15 +326,15 @@ func ensureNamedTenant(ctx context.Context, tx pgx.Tx, name string) (int64, erro
} }
func ensureUser(ctx context.Context, tx pgx.Tx, passwordHash string) (int64, error) { func ensureUser(ctx context.Context, tx pgx.Tx, passwordHash string) (int64, error) {
return ensureNamedUser(ctx, tx, "admin@geo.local", "Admin", passwordHash) return ensureNamedUser(ctx, tx, "admin@geo.local", "13800138000", "Admin", passwordHash)
} }
func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash string) (int64, error) { func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, phone, name, passwordHash string) (int64, error) {
var userID int64 var userID int64
err := tx.QueryRow(ctx, ` err := tx.QueryRow(ctx, `
WITH ensured AS ( WITH ensured AS (
INSERT INTO users (email, password_hash, name, status) INSERT INTO users (email, phone, password_hash, name, status)
VALUES ($1, $2, $3, 'active') VALUES ($1, $2, $3, $4, 'active')
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
RETURNING id RETURNING id
) )
@@ -342,7 +342,7 @@ func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash s
UNION ALL UNION ALL
SELECT id FROM users WHERE email = $1 SELECT id FROM users WHERE email = $1
LIMIT 1 LIMIT 1
`, email, passwordHash, name).Scan(&userID) `, email, phone, passwordHash, name).Scan(&userID)
return userID, wrapSeedErr("insert user", err) return userID, wrapSeedErr("insert user", err)
} }
@@ -412,7 +412,7 @@ func ensureSecondaryTenantUser(
return 0, 0, err return 0, 0, err
} }
userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "test", string(hash)) userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "13800138001", "test", string(hash))
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
+28 -6
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -37,8 +38,9 @@ func NewAuthService(
} }
type LoginRequest struct { type LoginRequest struct {
Email string `json:"email" binding:"required,email"` Identifier string `json:"identifier"`
Password string `json:"password" binding:"required"` Email string `json:"email"`
Password string `json:"password" binding:"required"`
} }
type LoginResponse struct { type LoginResponse struct {
@@ -50,8 +52,9 @@ type LoginResponse struct {
type UserInfo struct { type UserInfo struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Email string `json:"email"` Email *string `json:"email"`
Name string `json:"name"` Phone string `json:"phone"`
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"` AvatarURL *string `json:"avatar_url"`
TenantID int64 `json:"tenant_id"` TenantID int64 `json:"tenant_id"`
PrimaryWorkspaceID int64 `json:"primary_workspace_id"` PrimaryWorkspaceID int64 `json:"primary_workspace_id"`
@@ -86,11 +89,23 @@ type KolProfileBrief struct {
} }
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) { func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
user, err := s.repo.GetUserByEmail(ctx, req.Email) identifier := strings.TrimSpace(req.Identifier)
if identifier == "" {
identifier = strings.TrimSpace(req.Email)
}
if identifier == "" {
return nil, response.ErrBadRequest(40001, "invalid_params", "login identifier is required")
}
user, err := s.repo.GetUserByLoginIdentifier(ctx, identifier)
if err != nil { if err != nil {
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect") return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
} }
if user.Status != "active" {
return nil, response.ErrForbidden(40311, "user_disabled", "user account is disabled")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect") return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
} }
@@ -126,6 +141,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
User: UserInfo{ User: UserInfo{
ID: user.ID, ID: user.ID,
Email: user.Email, Email: user.Email,
Phone: user.Phone,
Name: user.Name, Name: user.Name,
AvatarURL: user.AvatarURL, AvatarURL: user.AvatarURL,
TenantID: membership.TenantID, TenantID: membership.TenantID,
@@ -154,7 +170,12 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh
return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired") return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired")
} }
pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.PrimaryWorkspaceID, claims.Role) membership, err := s.repo.GetTenantMembershipByTenantAndUser(ctx, claims.TenantID, claims.UserID)
if err != nil {
return nil, response.ErrUnauthorized(40123, "membership_revoked", "tenant membership is no longer active")
}
pair, err := s.jwt.Issue(membership.UserID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole)
if err != nil { if err != nil {
return nil, fmt.Errorf("issue token: %w", err) return nil, fmt.Errorf("issue token: %w", err)
} }
@@ -202,6 +223,7 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
return &UserInfo{ return &UserInfo{
ID: user.ID, ID: user.ID,
Email: user.Email, Email: user.Email,
Phone: user.Phone,
Name: user.Name, Name: user.Name,
AvatarURL: user.AvatarURL, AvatarURL: user.AvatarURL,
TenantID: actor.TenantID, TenantID: actor.TenantID,
+3 -3
View File
@@ -4,10 +4,10 @@ import "time"
type User struct { type User struct {
ID int64 ID int64
Email string Email *string
Phone *string Phone string
PasswordHash string PasswordHash string
Name string Name *string
AvatarURL *string AvatarURL *string
Status string Status string
CreatedAt time.Time CreatedAt time.Time
+32 -10
View File
@@ -4,15 +4,17 @@ import (
"context" "context"
"time" "time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated" "github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
) )
type UserRecord struct { type UserRecord struct {
ID int64 ID int64
Email string Email *string
Phone *string Phone string
PasswordHash string PasswordHash string
Name string Name *string
AvatarURL *string AvatarURL *string
Status string Status string
CreatedAt time.Time CreatedAt time.Time
@@ -30,6 +32,7 @@ type MembershipRecord struct {
type AuthRepository interface { type AuthRepository interface {
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
GetUserByLoginIdentifier(ctx context.Context, identifier string) (*UserRecord, error)
GetUserByID(ctx context.Context, id int64) (*UserRecord, error) GetUserByID(ctx context.Context, id int64) (*UserRecord, error)
GetTenantMembership(ctx context.Context, userID int64) (*MembershipRecord, error) GetTenantMembership(ctx context.Context, userID int64) (*MembershipRecord, error)
GetTenantMembershipByTenantAndUser(ctx context.Context, tenantID, userID int64) (*MembershipRecord, error) GetTenantMembershipByTenantAndUser(ctx context.Context, tenantID, userID int64) (*MembershipRecord, error)
@@ -44,17 +47,36 @@ func NewAuthRepository(db generated.DBTX) AuthRepository {
} }
func (r *authRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) { func (r *authRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
row, err := r.q.GetUserByEmail(ctx, email) row, err := r.q.GetUserByEmail(ctx, pgtype.Text{String: email, Valid: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &UserRecord{ return &UserRecord{
ID: row.ID, ID: row.ID,
Email: row.Email, Email: nullableText(row.Email),
Phone: nullableText(row.Phone), Phone: row.Phone,
PasswordHash: row.PasswordHash, PasswordHash: row.PasswordHash,
Name: row.Name, Name: nullableText(row.Name),
AvatarURL: nullableText(row.AvatarUrl),
Status: row.Status,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil
}
func (r *authRepository) GetUserByLoginIdentifier(ctx context.Context, identifier string) (*UserRecord, error) {
row, err := r.q.GetUserByLoginIdentifier(ctx, pgtype.Text{String: identifier, Valid: true})
if err != nil {
return nil, err
}
return &UserRecord{
ID: row.ID,
Email: nullableText(row.Email),
Phone: row.Phone,
PasswordHash: row.PasswordHash,
Name: nullableText(row.Name),
AvatarURL: nullableText(row.AvatarUrl), AvatarURL: nullableText(row.AvatarUrl),
Status: row.Status, Status: row.Status,
CreatedAt: timeFromTimestamp(row.CreatedAt), CreatedAt: timeFromTimestamp(row.CreatedAt),
@@ -70,10 +92,10 @@ func (r *authRepository) GetUserByID(ctx context.Context, id int64) (*UserRecord
return &UserRecord{ return &UserRecord{
ID: row.ID, ID: row.ID,
Email: row.Email, Email: nullableText(row.Email),
Phone: nullableText(row.Phone), Phone: row.Phone,
PasswordHash: row.PasswordHash, PasswordHash: row.PasswordHash,
Name: row.Name, Name: nullableText(row.Name),
AvatarURL: nullableText(row.AvatarUrl), AvatarURL: nullableText(row.AvatarUrl),
Status: row.Status, Status: row.Status,
CreatedAt: timeFromTimestamp(row.CreatedAt), CreatedAt: timeFromTimestamp(row.CreatedAt),
@@ -101,17 +101,17 @@ WHERE email = $1 AND deleted_at IS NULL
type GetUserByEmailRow struct { type GetUserByEmailRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Email string `json:"email"` Email pgtype.Text `json:"email"`
Phone pgtype.Text `json:"phone"` Phone string `json:"phone"`
PasswordHash string `json:"password_hash"` PasswordHash string `json:"password_hash"`
Name string `json:"name"` Name pgtype.Text `json:"name"`
AvatarUrl pgtype.Text `json:"avatar_url"` AvatarUrl pgtype.Text `json:"avatar_url"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) { func (q *Queries) GetUserByEmail(ctx context.Context, email pgtype.Text) (GetUserByEmailRow, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email) row := q.db.QueryRow(ctx, getUserByEmail, email)
var i GetUserByEmailRow var i GetUserByEmailRow
err := row.Scan( err := row.Scan(
@@ -136,10 +136,10 @@ WHERE id = $1 AND deleted_at IS NULL
type GetUserByIDRow struct { type GetUserByIDRow struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Email string `json:"email"` Email pgtype.Text `json:"email"`
Phone pgtype.Text `json:"phone"` Phone string `json:"phone"`
PasswordHash string `json:"password_hash"` PasswordHash string `json:"password_hash"`
Name string `json:"name"` Name pgtype.Text `json:"name"`
AvatarUrl pgtype.Text `json:"avatar_url"` AvatarUrl pgtype.Text `json:"avatar_url"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
@@ -162,3 +162,39 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er
) )
return i, err return i, err
} }
const getUserByLoginIdentifier = `-- name: GetUserByLoginIdentifier :one
SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at
FROM users
WHERE (email = $1 OR phone = $1)
AND deleted_at IS NULL
`
type GetUserByLoginIdentifierRow struct {
ID int64 `json:"id"`
Email pgtype.Text `json:"email"`
Phone string `json:"phone"`
PasswordHash string `json:"password_hash"`
Name pgtype.Text `json:"name"`
AvatarUrl pgtype.Text `json:"avatar_url"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) GetUserByLoginIdentifier(ctx context.Context, loginIdentifier pgtype.Text) (GetUserByLoginIdentifierRow, error) {
row := q.db.QueryRow(ctx, getUserByLoginIdentifier, loginIdentifier)
var i GetUserByLoginIdentifierRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Phone,
&i.PasswordHash,
&i.Name,
&i.AvatarUrl,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -21,6 +21,27 @@ type AiPlatform struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type AiPointUsageLog struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
OperatorID pgtype.Int8 `json:"operator_id"`
QuotaReservationID pgtype.Int8 `json:"quota_reservation_id"`
UsageType string `json:"usage_type"`
ResourceType pgtype.Text `json:"resource_type"`
ResourceID pgtype.Int8 `json:"resource_id"`
ResourceUid pgtype.Text `json:"resource_uid"`
RequestChars int32 `json:"request_chars"`
BaseChars int32 `json:"base_chars"`
Points int32 `json:"points"`
Status string `json:"status"`
Model pgtype.Text `json:"model"`
MetadataJson []byte `json:"metadata_json"`
ErrorMessage pgtype.Text `json:"error_message"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Article struct { type Article struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"` TenantID int64 `json:"tenant_id"`
@@ -149,6 +170,15 @@ type DesktopClient struct {
RevokedAt pgtype.Timestamptz `json:"revoked_at"` RevokedAt pgtype.Timestamptz `json:"revoked_at"`
} }
type DesktopClientPrimaryLease struct {
TenantID int64 `json:"tenant_id"`
WorkspaceID int64 `json:"workspace_id"`
PrimaryClientID pgtype.UUID `json:"primary_client_id"`
LeaseExpiresAt pgtype.Timestamptz `json:"lease_expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type DesktopPublishJob struct { type DesktopPublishJob struct {
ID int64 `json:"id"` ID int64 `json:"id"`
DesktopID pgtype.UUID `json:"desktop_id"` DesktopID pgtype.UUID `json:"desktop_id"`
@@ -708,10 +738,10 @@ type TenantQuotaLedger struct {
type User struct { type User struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Email string `json:"email"` Email pgtype.Text `json:"email"`
Phone pgtype.Text `json:"phone"` Phone string `json:"phone"`
PasswordHash string `json:"password_hash"` PasswordHash string `json:"password_hash"`
Name string `json:"name"` Name pgtype.Text `json:"name"`
AvatarUrl pgtype.Text `json:"avatar_url"` AvatarUrl pgtype.Text `json:"avatar_url"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
@@ -6,6 +6,8 @@ package generated
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype"
) )
type Querier interface { type Querier interface {
@@ -90,8 +92,9 @@ type Querier interface {
GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams) (GetTemplateByIDRow, error) GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams) (GetTemplateByIDRow, error)
GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, error) GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, error)
GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error) GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByEmail(ctx context.Context, email pgtype.Text) (GetUserByEmailRow, error)
GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error)
GetUserByLoginIdentifier(ctx context.Context, loginIdentifier pgtype.Text) (GetUserByLoginIdentifierRow, error)
HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDesktopClientParams) (DesktopClient, error) HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDesktopClientParams) (DesktopClient, error)
InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error) InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error)
InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerParams) (int64, error) InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerParams) (int64, error)
@@ -3,6 +3,12 @@ SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, up
FROM users FROM users
WHERE email = sqlc.arg(email) AND deleted_at IS NULL; WHERE email = sqlc.arg(email) AND deleted_at IS NULL;
-- name: GetUserByLoginIdentifier :one
SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at
FROM users
WHERE (email = sqlc.arg(login_identifier) OR phone = sqlc.arg(login_identifier))
AND deleted_at IS NULL;
-- name: GetUserByID :one -- name: GetUserByID :one
SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at
FROM users FROM users
@@ -1,13 +1,19 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE users ( CREATE TABLE users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL, email VARCHAR(255),
phone VARCHAR(20), phone VARCHAR(20) NOT NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL, name VARCHAR(100),
avatar_url TEXT, avatar_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active', status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ deleted_at TIMESTAMPTZ
); );
CREATE UNIQUE INDEX uk_users_email_active ON users(email) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX uk_users_email_active ON users(email) WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX uk_users_phone_active ON users(phone) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_email_trgm_active ON users USING GIN (email gin_trgm_ops) WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_users_phone_trgm_active ON users USING GIN (phone gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_name_trgm_active ON users USING GIN (name gin_trgm_ops) WHERE name IS NOT NULL AND deleted_at IS NULL;
@@ -0,0 +1,24 @@
DROP INDEX IF EXISTS idx_users_name_trgm_active;
DROP INDEX IF EXISTS idx_users_phone_trgm_active;
DROP INDEX IF EXISTS idx_users_email_trgm_active;
DROP INDEX IF EXISTS idx_users_status_created_at;
DROP INDEX IF EXISTS uk_users_phone_active;
UPDATE users
SET email = 'user-' || id || '@rollback.local'
WHERE email IS NULL;
UPDATE users
SET name = COALESCE(NULLIF(phone, ''), email, 'user-' || id)
WHERE name IS NULL;
ALTER TABLE users
ALTER COLUMN email SET NOT NULL,
ALTER COLUMN phone DROP NOT NULL,
ALTER COLUMN name SET NOT NULL;
DROP INDEX IF EXISTS uk_users_email_active;
CREATE UNIQUE INDEX uk_users_email_active
ON users(email)
WHERE deleted_at IS NULL;
@@ -0,0 +1,36 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
UPDATE users
SET phone = '0' || LPAD(id::text, 19, '0')
WHERE phone IS NULL;
ALTER TABLE users
ALTER COLUMN email DROP NOT NULL,
ALTER COLUMN phone SET NOT NULL,
ALTER COLUMN name DROP NOT NULL;
DROP INDEX IF EXISTS uk_users_email_active;
CREATE UNIQUE INDEX uk_users_email_active
ON users(email)
WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_phone_active
ON users(phone)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_status_created_at
ON users(status, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_email_trgm_active
ON users USING GIN (email gin_trgm_ops)
WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_phone_trgm_active
ON users USING GIN (phone gin_trgm_ops)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_name_trgm_active
ON users USING GIN (name gin_trgm_ops)
WHERE name IS NOT NULL AND deleted_at IS NULL;