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:
@@ -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) {
|
||||
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
|
||||
err := tx.QueryRow(ctx, `
|
||||
WITH ensured AS (
|
||||
INSERT INTO users (email, password_hash, name, status)
|
||||
VALUES ($1, $2, $3, 'active')
|
||||
INSERT INTO users (email, phone, password_hash, name, status)
|
||||
VALUES ($1, $2, $3, $4, 'active')
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
)
|
||||
@@ -342,7 +342,7 @@ func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash s
|
||||
UNION ALL
|
||||
SELECT id FROM users WHERE email = $1
|
||||
LIMIT 1
|
||||
`, email, passwordHash, name).Scan(&userID)
|
||||
`, email, phone, passwordHash, name).Scan(&userID)
|
||||
return userID, wrapSeedErr("insert user", err)
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ func ensureSecondaryTenantUser(
|
||||
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 {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -37,8 +38,9 @@ func NewAuthService(
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
@@ -50,8 +52,9 @@ type LoginResponse struct {
|
||||
|
||||
type UserInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
TenantID int64 `json:"tenant_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) {
|
||||
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 {
|
||||
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 {
|
||||
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{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
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{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
TenantID: actor.TenantID,
|
||||
|
||||
@@ -4,10 +4,10 @@ import "time"
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
Phone *string
|
||||
Email *string
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Name string
|
||||
Name *string
|
||||
AvatarURL *string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
|
||||
@@ -4,15 +4,17 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
|
||||
)
|
||||
|
||||
type UserRecord struct {
|
||||
ID int64
|
||||
Email string
|
||||
Phone *string
|
||||
Email *string
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Name string
|
||||
Name *string
|
||||
AvatarURL *string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
@@ -30,6 +32,7 @@ type MembershipRecord struct {
|
||||
|
||||
type AuthRepository interface {
|
||||
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
|
||||
GetUserByLoginIdentifier(ctx context.Context, identifier string) (*UserRecord, error)
|
||||
GetUserByID(ctx context.Context, id int64) (*UserRecord, error)
|
||||
GetTenantMembership(ctx context.Context, 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) {
|
||||
row, err := r.q.GetUserByEmail(ctx, email)
|
||||
row, err := r.q.GetUserByEmail(ctx, pgtype.Text{String: email, Valid: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserRecord{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
Phone: nullableText(row.Phone),
|
||||
Email: nullableText(row.Email),
|
||||
Phone: row.Phone,
|
||||
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),
|
||||
Status: row.Status,
|
||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||
@@ -70,10 +92,10 @@ func (r *authRepository) GetUserByID(ctx context.Context, id int64) (*UserRecord
|
||||
|
||||
return &UserRecord{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
Phone: nullableText(row.Phone),
|
||||
Email: nullableText(row.Email),
|
||||
Phone: row.Phone,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Name: row.Name,
|
||||
Name: nullableText(row.Name),
|
||||
AvatarURL: nullableText(row.AvatarUrl),
|
||||
Status: row.Status,
|
||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||
|
||||
@@ -101,17 +101,17 @@ WHERE email = $1 AND deleted_at IS NULL
|
||||
|
||||
type GetUserByEmailRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name string `json:"name"`
|
||||
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) 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)
|
||||
var i GetUserByEmailRow
|
||||
err := row.Scan(
|
||||
@@ -136,10 +136,10 @@ WHERE id = $1 AND deleted_at IS NULL
|
||||
|
||||
type GetUserByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name string `json:"name"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
@@ -162,3 +162,39 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er
|
||||
)
|
||||
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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
@@ -149,6 +170,15 @@ type DesktopClient struct {
|
||||
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 {
|
||||
ID int64 `json:"id"`
|
||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||
@@ -708,10 +738,10 @@ type TenantQuotaLedger struct {
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name string `json:"name"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
|
||||
@@ -6,6 +6,8 @@ package generated
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
@@ -90,8 +92,9 @@ type Querier interface {
|
||||
GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams) (GetTemplateByIDRow, error)
|
||||
GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, 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)
|
||||
GetUserByLoginIdentifier(ctx context.Context, loginIdentifier pgtype.Text) (GetUserByLoginIdentifierRow, error)
|
||||
HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDesktopClientParams) (DesktopClient, error)
|
||||
InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (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
|
||||
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
|
||||
SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at
|
||||
FROM users
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(20),
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(100),
|
||||
avatar_url TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
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;
|
||||
Reference in New Issue
Block a user