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) {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user