Merge pull request #13 from Nucrea/dev

User flow improvements
This commit is contained in:
Sergey Chubaryan 2025-02-22 15:54:16 +03:00 committed by GitHub
commit c4c8f0c6ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 252 additions and 151 deletions

View File

@ -122,9 +122,10 @@ func (a *App) Run(p RunParams) {
shortlinkRepo = repos.NewShortlinkRepo(sqlDb, tracer) shortlinkRepo = repos.NewShortlinkRepo(sqlDb, tracer)
eventRepo = repos.NewEventRepo(kafka) eventRepo = repos.NewEventRepo(kafka)
userCache = cache.NewCacheInmemSharded[models.UserDTO](cache.ShardingTypeInteger) userCache = cache.NewCacheInmemSharded[models.UserDTO](cache.ShardingTypeInteger)
jwtCache = cache.NewCacheInmemSharded[string](cache.ShardingTypeJWT) loginAttemptsCache = cache.NewCacheInmem[string, int]()
linksCache = cache.NewCacheInmem[string, string]() jwtCache = cache.NewCacheInmemSharded[string](cache.ShardingTypeJWT)
linksCache = cache.NewCacheInmem[string, string]()
) )
// Periodically trigger cache cleanup // Periodically trigger cache cleanup
@ -140,20 +141,22 @@ func (a *App) Run(p RunParams) {
userCache.CheckExpired() userCache.CheckExpired()
jwtCache.CheckExpired() jwtCache.CheckExpired()
linksCache.CheckExpired() linksCache.CheckExpired()
loginAttemptsCache.CheckExpired()
} }
} }
}() }()
userService = services.NewUserService( userService = services.NewUserService(
services.UserServiceDeps{ services.UserServiceDeps{
Jwt: jwtUtil, Jwt: jwtUtil,
Password: passwordUtil, Password: passwordUtil,
UserRepo: userRepo, UserRepo: userRepo,
UserCache: userCache, UserCache: userCache,
JwtCache: jwtCache, LoginAttemptsCache: loginAttemptsCache,
EventRepo: *eventRepo, JwtCache: jwtCache,
ActionTokenRepo: actionTokenRepo, EventRepo: *eventRepo,
Logger: logger, ActionTokenRepo: actionTokenRepo,
Logger: logger,
}, },
) )
shortlinkService = services.NewShortlinkSevice( shortlinkService = services.NewShortlinkSevice(

View File

@ -16,9 +16,9 @@ type createUserInput struct {
} }
type createUserOutput struct { type createUserOutput struct {
Id string `json:"id"` Id string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` FullName string `json:"fullName"`
} }
func NewUserCreateHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc { func NewUserCreateHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
@ -37,9 +37,9 @@ func NewUserCreateHandler(log logger.Logger, userService services.UserService) g
} }
return createUserOutput{ return createUserOutput{
Id: user.Id, Id: user.Id,
Email: user.Email, Email: user.Email,
Name: user.Name, FullName: user.FullName,
}, nil }, nil
}, },
) )

View File

@ -16,7 +16,7 @@ type inputSendRestorePassword struct {
func NewUserSendRestorePasswordHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc { func NewUserSendRestorePasswordHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
return httpserver.WrapGin(log, return httpserver.WrapGin(log,
func(ctx context.Context, input inputSendRestorePassword) (interface{}, error) { func(ctx context.Context, input inputSendRestorePassword) (interface{}, error) {
err := userService.SendEmailForgotPassword(ctx, input.Email) err := userService.RequestRestorePassword(ctx, input.Email)
return nil, err return nil, err
}, },
) )

View File

@ -16,7 +16,7 @@ type inputSendVerify struct {
func NewUserSendVerifyEmailHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc { func NewUserSendVerifyEmailHandler(log logger.Logger, userService services.UserService) gin.HandlerFunc {
return httpserver.WrapGin(log, return httpserver.WrapGin(log,
func(ctx context.Context, input inputSendVerify) (interface{}, error) { func(ctx context.Context, input inputSendVerify) (interface{}, error) {
err := userService.SendEmailVerifyUser(ctx, input.Email) err := userService.RequestVerifyUser(ctx, input.Email)
return nil, err return nil, err
}, },
) )

View File

@ -2,13 +2,11 @@ package models
import "time" import "time"
type ActionTokenTarget int type ActionTokenTarget string
const ( const (
_ ActionTokenTarget = iota ActionTokenTargetRestorePassword ActionTokenTarget = "restore"
ActionTokenTargetForgotPassword ActionTokenTargetVerifyEmail ActionTokenTarget = "verify"
ActionTokenTargetLogin2FA
ActionTokenVerifyEmail
) )
type ActionTokenDTO struct { type ActionTokenDTO struct {

View File

@ -5,10 +5,10 @@ type UserDTO struct {
Email string Email string
EmailVerified bool EmailVerified bool
Secret string Secret string
Name string FullName string
} }
type UserUpdateDTO struct { type UserUpdateDTO struct {
Secret string Secret string
Name string FullName string
} }

View File

@ -26,7 +26,7 @@ type actionTokenRepo struct {
func (a *actionTokenRepo) CreateActionToken(ctx context.Context, dto models.ActionTokenDTO) (*models.ActionTokenDTO, error) { func (a *actionTokenRepo) CreateActionToken(ctx context.Context, dto models.ActionTokenDTO) (*models.ActionTokenDTO, error) {
query := ` query := `
insert into insert into
action_tokens (user_id, value, target, expiration) action_tokens (user_id, value, target, expires_at)
values ($1, $2, $3, $4) values ($1, $2, $3, $4)
returning id;` returning id;`
row := a.db.QueryRowContext(ctx, query, dto.UserId, dto.Value, dto.Target, dto.Expiration) row := a.db.QueryRowContext(ctx, query, dto.UserId, dto.Value, dto.Target, dto.Expiration)
@ -51,7 +51,7 @@ func (a *actionTokenRepo) GetActionToken(ctx context.Context, value string, targ
select id, user_id from action_tokens select id, user_id from action_tokens
where where
value=$1 and target=$2 value=$1 and target=$2
and CURRENT_TIMESTAMP < expiration;` and CURRENT_TIMESTAMP < expires_at;`
row := a.db.QueryRowContext(ctx, query, value, target) row := a.db.QueryRowContext(ctx, query, value, target)
err := row.Scan(&dto.Id, &dto.UserId) err := row.Scan(&dto.Id, &dto.UserId)

View File

@ -11,9 +11,9 @@ import (
) )
type ShortlinkDTO struct { type ShortlinkDTO struct {
Id string Id string
Url string Url string
Expiration time.Time ExpiresAt time.Time
} }
type ShortlinkRepo interface { type ShortlinkRepo interface {
@ -35,8 +35,8 @@ func (u *shortlinkRepo) AddShortlink(ctx context.Context, dto ShortlinkDTO) erro
_, span := u.tracer.Start(ctx, "postgres::AddShortlink") _, span := u.tracer.Start(ctx, "postgres::AddShortlink")
defer span.End() defer span.End()
query := `insert into shortlinks (id, url, expiration) values ($1, $2, $3);` query := `insert into shortlinks (url, expires_at) values ($1, $2);`
_, err := u.db.ExecContext(ctx, query, dto.Id, dto.Url, dto.Expiration) _, err := u.db.ExecContext(ctx, query, dto.Url, dto.ExpiresAt)
return err return err
} }
@ -44,14 +44,14 @@ func (u *shortlinkRepo) GetShortlink(ctx context.Context, id string) (*Shortlink
_, span := u.tracer.Start(ctx, "postgres::GetShortlink") _, span := u.tracer.Start(ctx, "postgres::GetShortlink")
defer span.End() defer span.End()
query := `select url, expiration from shortlinks where id = $1;` query := `select url, expires_at from shortlinks where id = $1;`
row := u.db.QueryRowContext(ctx, query, id) row := u.db.QueryRowContext(ctx, query, id)
if err := row.Err(); err != nil { if err := row.Err(); err != nil {
return nil, err return nil, err
} }
dto := &ShortlinkDTO{Id: id} dto := &ShortlinkDTO{Id: id}
err := row.Scan(&dto.Url, &dto.Expiration) err := row.Scan(&dto.Url, &dto.ExpiresAt)
if err == nil { if err == nil {
return dto, nil return dto, nil
} }

View File

@ -10,16 +10,10 @@ import (
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
) )
// type userDAO struct {
// Id string `json:"id"`
// Login string `json:"login"`
// Secret string `json:"secret"`
// Name string `json:"name"`
// }
type UserRepo interface { type UserRepo interface {
CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error) CreateUser(ctx context.Context, dto models.UserDTO) (*models.UserDTO, error)
UpdateUser(ctx context.Context, userId string, dto models.UserUpdateDTO) error UpdateUser(ctx context.Context, userId string, dto models.UserUpdateDTO) error
DeactivateUser(ctx context.Context, userId string) error
SetUserEmailVerified(ctx context.Context, userId string) error SetUserEmailVerified(ctx context.Context, userId string) error
GetUserById(ctx context.Context, id string) (*models.UserDTO, error) GetUserById(ctx context.Context, id string) (*models.UserDTO, error)
GetUserByEmail(ctx context.Context, login string) (*models.UserDTO, error) GetUserByEmail(ctx context.Context, login string) (*models.UserDTO, error)
@ -38,8 +32,8 @@ func (u *userRepo) CreateUser(ctx context.Context, dto models.UserDTO) (*models.
_, span := u.tracer.Start(ctx, "postgres::CreateUser") _, span := u.tracer.Start(ctx, "postgres::CreateUser")
defer span.End() defer span.End()
query := `insert into users (email, secret, name) values ($1, $2, $3) returning id;` query := `insert into users (email, secret, full_name) values ($1, $2, $3) returning id;`
row := u.db.QueryRowContext(ctx, query, dto.Email, dto.Secret, dto.Name) row := u.db.QueryRowContext(ctx, query, dto.Email, dto.Secret, dto.FullName)
id := "" id := ""
if err := row.Scan(&id); err != nil { if err := row.Scan(&id); err != nil {
@ -47,10 +41,10 @@ func (u *userRepo) CreateUser(ctx context.Context, dto models.UserDTO) (*models.
} }
return &models.UserDTO{ return &models.UserDTO{
Id: id, Id: id,
Email: dto.Email, Email: dto.Email,
Secret: dto.Secret, Secret: dto.Secret,
Name: dto.Name, FullName: dto.FullName,
}, nil }, nil
} }
@ -58,8 +52,21 @@ func (u *userRepo) UpdateUser(ctx context.Context, userId string, dto models.Use
_, span := u.tracer.Start(ctx, "postgres::UpdateUser") _, span := u.tracer.Start(ctx, "postgres::UpdateUser")
defer span.End() defer span.End()
query := `update users set secret=$1, name=$2 where id = $3;` query := `update users set secret=$1, full_name=$2 where id = $3;`
_, err := u.db.ExecContext(ctx, query, dto.Secret, dto.Name, userId) _, err := u.db.ExecContext(ctx, query, dto.Secret, dto.FullName, userId)
if err != nil {
return err
}
return nil
}
func (u *userRepo) DeactivateUser(ctx context.Context, userId string) error {
_, span := u.tracer.Start(ctx, "postgres::DeactivateUser")
defer span.End()
query := `update users set active=false where id = $1;`
_, err := u.db.ExecContext(ctx, query, userId)
if err != nil { if err != nil {
return err return err
} }
@ -84,11 +91,13 @@ func (u *userRepo) GetUserById(ctx context.Context, id string) (*models.UserDTO,
_, span := u.tracer.Start(ctx, "postgres::GetUserById") _, span := u.tracer.Start(ctx, "postgres::GetUserById")
defer span.End() defer span.End()
query := `select id, email, secret, name, email_verified from users where id = $1;` query := `
select id, email, secret, full_name, email_verified
from users where id = $1 and active;`
row := u.db.QueryRowContext(ctx, query, id) row := u.db.QueryRowContext(ctx, query, id)
dto := &models.UserDTO{} dto := &models.UserDTO{}
err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name, &dto.EmailVerified) err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.FullName, &dto.EmailVerified)
if err == nil { if err == nil {
return dto, nil return dto, nil
} }
@ -103,11 +112,12 @@ func (u *userRepo) GetUserByEmail(ctx context.Context, login string) (*models.Us
_, span := u.tracer.Start(ctx, "postgres::GetUserByEmail") _, span := u.tracer.Start(ctx, "postgres::GetUserByEmail")
defer span.End() defer span.End()
query := `select id, email, secret, name, email_verified from users where email = $1;` query := `select id, email, secret, full_name, email_verified
from users where email = $1 and active;`
row := u.db.QueryRowContext(ctx, query, login) row := u.db.QueryRowContext(ctx, query, login)
dto := &models.UserDTO{} dto := &models.UserDTO{}
err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.Name, &dto.EmailVerified) err := row.Scan(&dto.Id, &dto.Email, &dto.Secret, &dto.FullName, &dto.EmailVerified)
if err == nil { if err == nil {
return dto, nil return dto, nil
} }

View File

@ -48,9 +48,9 @@ func (s *shortlinkService) CreateShortlink(ctx context.Context, url string) (str
expiration := time.Now().Add(7 * 24 * time.Hour) expiration := time.Now().Add(7 * 24 * time.Hour)
dto := repos.ShortlinkDTO{ dto := repos.ShortlinkDTO{
Id: id, Id: id,
Url: url, Url: url,
Expiration: expiration, ExpiresAt: expiration,
} }
if err := s.repo.AddShortlink(ctx, dto); err != nil { if err := s.repo.AddShortlink(ctx, dto); err != nil {
return "", err return "", err
@ -73,7 +73,7 @@ func (s *shortlinkService) GetShortlink(ctx context.Context, id string) (string,
if link == nil { if link == nil {
return "", ErrShortlinkNotexist return "", ErrShortlinkNotexist
} }
if time.Now().After(link.Expiration) { if time.Now().After(link.ExpiresAt) {
return "", ErrShortlinkExpired return "", ErrShortlinkExpired
} }

View File

@ -8,6 +8,7 @@ import (
"backend/pkg/logger" "backend/pkg/logger"
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@ -31,13 +32,16 @@ type UserService interface {
CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error) CreateUser(ctx context.Context, params UserCreateParams) (*models.UserDTO, error)
AuthenticateUser(ctx context.Context, login, password string) (string, error) AuthenticateUser(ctx context.Context, login, password string) (string, error)
ValidateAuthToken(ctx context.Context, tokenStr string) (*models.UserDTO, error) ValidateAuthToken(ctx context.Context, tokenStr string) (*models.UserDTO, error)
// TODO: implement user deactivation flow
// DeactivateUser(ctx context.Context, userId string) error
VerifyEmail(ctx context.Context, actionToken string) error VerifyEmail(ctx context.Context, actionToken string) error
SendEmailForgotPassword(ctx context.Context, userId string) error
SendEmailVerifyUser(ctx context.Context, email string) error
ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error ChangePassword(ctx context.Context, userId, oldPassword, newPassword string) error
ChangePasswordWithToken(ctx context.Context, actionToken, newPassword string) error ChangePasswordWithToken(ctx context.Context, actionToken, newPassword string) error
RequestRestorePassword(ctx context.Context, email string) error
RequestVerifyUser(ctx context.Context, email string) error
} }
func NewUserService(deps UserServiceDeps) UserService { func NewUserService(deps UserServiceDeps) UserService {
@ -45,14 +49,15 @@ func NewUserService(deps UserServiceDeps) UserService {
} }
type UserServiceDeps struct { type UserServiceDeps struct {
Jwt utils.JwtUtil Jwt utils.JwtUtil
Password utils.PasswordUtil Password utils.PasswordUtil
UserRepo repos.UserRepo UserRepo repos.UserRepo
UserCache cache.Cache[string, models.UserDTO] UserCache cache.Cache[string, models.UserDTO]
JwtCache cache.Cache[string, string] JwtCache cache.Cache[string, string]
EventRepo repos.EventRepo LoginAttemptsCache cache.Cache[string, int]
ActionTokenRepo repos.ActionTokenRepo EventRepo repos.EventRepo
Logger logger.Logger ActionTokenRepo repos.ActionTokenRepo
Logger logger.Logger
} }
type userService struct { type userService struct {
@ -84,9 +89,9 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
} }
user := models.UserDTO{ user := models.UserDTO{
Email: params.Email, Email: strings.ToLower(params.Email),
Secret: string(secret), Secret: string(secret),
Name: params.Name, FullName: params.Name,
} }
result, err := u.deps.UserRepo.CreateUser(ctx, user) result, err := u.deps.UserRepo.CreateUser(ctx, user)
@ -104,6 +109,22 @@ func (u *userService) CreateUser(ctx context.Context, params UserCreateParams) (
} }
func (u *userService) AuthenticateUser(ctx context.Context, email, password string) (string, error) { func (u *userService) AuthenticateUser(ctx context.Context, email, password string) (string, error) {
attempts, ok := u.deps.LoginAttemptsCache.Get(email)
if ok && attempts >= 4 {
return "", fmt.Errorf("too many bad login attempts")
}
token, err := u.authenticateUser(ctx, email, password)
if err != nil {
u.deps.LoginAttemptsCache.Set(email, attempts+1, cache.Expiration{Ttl: 30 * time.Second})
return "", err
}
u.deps.LoginAttemptsCache.Del(email)
return token, nil
}
func (u *userService) authenticateUser(ctx context.Context, email, password string) (string, error) {
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email) user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil { if err != nil {
return "", err return "", err
@ -131,8 +152,18 @@ func (u *userService) AuthenticateUser(ctx context.Context, email, password stri
return jwt, nil return jwt, nil
} }
func (u *userService) DeactivateUser(ctx context.Context, userId string) error {
err := u.deps.UserRepo.DeactivateUser(ctx, userId)
if err != nil {
return err
}
u.deps.UserCache.Del(userId)
return nil
}
func (u *userService) VerifyEmail(ctx context.Context, actionToken string) error { func (u *userService) VerifyEmail(ctx context.Context, actionToken string) error {
token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenVerifyEmail) token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenTargetVerifyEmail)
if err != nil { if err != nil {
return err return err
} }
@ -149,64 +180,8 @@ func (u *userService) VerifyEmail(ctx context.Context, actionToken string) error
return nil return nil
} }
func (u *userService) SendEmailForgotPassword(ctx context.Context, email string) error {
// user, err := u.getUserById(ctx, userId)
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return err
}
actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
ctx,
models.ActionTokenDTO{
UserId: user.Id,
Value: uuid.New().String(),
Target: models.ActionTokenTargetForgotPassword,
Expiration: time.Now().Add(15 * time.Minute),
},
)
if err != nil {
return err
}
return u.deps.EventRepo.SendEmailForgotPassword(ctx, user.Email, actionToken.Value)
}
func (u *userService) sendEmailVerifyUser(ctx context.Context, userId, email string) error {
actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
ctx,
models.ActionTokenDTO{
UserId: userId,
Value: uuid.New().String(),
Target: models.ActionTokenVerifyEmail,
Expiration: time.Now().Add(1 * time.Hour),
},
)
if err != nil {
return err
}
return u.deps.EventRepo.SendEmailVerifyUser(ctx, email, actionToken.Value)
}
func (u *userService) SendEmailVerifyUser(ctx context.Context, email string) error {
//user, err := u.getUserById(ctx, userId)
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf("no such user")
}
if user.EmailVerified {
return fmt.Errorf("user already verified")
}
return u.sendEmailVerifyUser(ctx, user.Id, user.Email)
}
func (u *userService) ChangePasswordWithToken(ctx context.Context, actionToken, newPassword string) error { func (u *userService) ChangePasswordWithToken(ctx context.Context, actionToken, newPassword string) error {
token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenTargetForgotPassword) token, err := u.deps.ActionTokenRepo.GetActionToken(ctx, actionToken, models.ActionTokenTargetRestorePassword)
if err != nil { if err != nil {
return err return err
} }
@ -257,8 +232,8 @@ func (u *userService) updatePassword(ctx context.Context, user models.UserDTO, n
} }
if err = u.deps.UserRepo.UpdateUser(ctx, user.Id, models.UserUpdateDTO{ if err = u.deps.UserRepo.UpdateUser(ctx, user.Id, models.UserUpdateDTO{
Secret: newSecret, Secret: newSecret,
Name: user.Name, FullName: user.FullName,
}); err != nil { }); err != nil {
return err return err
} }
@ -307,3 +282,57 @@ func (u *userService) ValidateAuthToken(ctx context.Context, tokenStr string) (*
return user, nil return user, nil
} }
func (u *userService) RequestRestorePassword(ctx context.Context, email string) error {
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return err
}
actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
ctx,
models.ActionTokenDTO{
UserId: user.Id,
Value: uuid.New().String(),
Target: models.ActionTokenTargetRestorePassword,
Expiration: time.Now().Add(15 * time.Minute),
},
)
if err != nil {
return err
}
return u.deps.EventRepo.SendEmailForgotPassword(ctx, user.Email, actionToken.Value)
}
func (u *userService) RequestVerifyUser(ctx context.Context, email string) error {
user, err := u.deps.UserRepo.GetUserByEmail(ctx, email)
if err != nil {
return err
}
if user == nil {
return fmt.Errorf("no such user")
}
if user.EmailVerified {
return fmt.Errorf("user already verified")
}
return u.sendEmailVerifyUser(ctx, user.Id, user.Email)
}
func (u *userService) sendEmailVerifyUser(ctx context.Context, userId, email string) error {
actionToken, err := u.deps.ActionTokenRepo.CreateActionToken(
ctx,
models.ActionTokenDTO{
UserId: userId,
Value: uuid.New().String(),
Target: models.ActionTokenTargetVerifyEmail,
Expiration: time.Now().Add(1 * time.Hour),
},
)
if err != nil {
return err
}
return u.deps.EventRepo.SendEmailVerifyUser(ctx, email, actionToken.Value)
}

18
sql/00_common.sql Normal file
View File

@ -0,0 +1,18 @@
create or replace function trg_proc_row_updated()
returns trigger as $$
begin
if new is distinct from old then
new.updated_at = now();
end if;
return new;
end;
$$ language plpgsql;
create or replace function trg_proc_row_created()
returns trigger as $$
begin
new.created_at = now();
new.updated_at = now();
return new;
end;
$$ language plpgsql;

View File

@ -1,11 +1,25 @@
create table if not exists users ( create table if not exists users (
id int generated always as identity, id integer primary key generated always as identity,
email text unique not null, email varchar(256) unique not null,
secret text not null, secret varchar(256) not null,
name text not null, full_name varchar(256) not null,
email_verified boolean not null default false, email_verified boolean not null default false,
active boolean default true,
primary key (id) created_at timestamp,
updated_at timestamp
); );
create index if not exists users_email_idx on users(email); alter table users alter column active set default true;
create index if not exists idx_users_email on users(email);
create or replace trigger trg_user_created
before insert on users
for each row
execute function trg_proc_row_created();
create or replace trigger trg_user_updated
before update on users
for each row
when(new is distinct from old)
execute function trg_proc_row_updated();

View File

@ -1,5 +1,18 @@
create table if not exists shortlinks ( create table if not exists shortlinks (
id text primary key, id int generated always as identity,
url text, url text not null,
expiration date expires_at timestamp not null,
); created_at timestamp,
updated_at timestamp
);
create or replace trigger trg_shortlink_created
before insert on shortlinks
for each row
execute function trg_proc_row_created();
create or replace trigger trg_shortlink_updated
before update on shortlinks
for each row
when (new is distinct from old)
execute function trg_proc_row_updated();

View File

@ -1,9 +1,25 @@
create table if not exists action_tokens ( create table if not exists action_tokens (
id int generated always as identity, id int generated always as identity,
user_id int, user_id int references users(id),
value text, value text not null,
target int, target text not null,
expiration timestamp, expires_at timestamp not null,
created_at timestamp,
updated_at timestamp,
constraint pk_action_tokens_id primary key(id),
constraint chk_action_tokens_target check(target in ('verify', 'restore'))
);
primary key(id) create index if not exists idx_action_tokens_value on action_tokens(value);
);
create or replace trigger trg_action_token_created
before insert on action_tokens
for each row
execute function trg_proc_row_created();
create or replace trigger trg_action_token_updated
before update on action_tokens
for each row
when (new is distinct from old)
execute function trg_proc_row_updated();