Skip to content

Commit

Permalink
Merge pull request #4 from lexica-app/GEM-27
Browse files Browse the repository at this point in the history
[GEM-27] - User Onboarding
  • Loading branch information
nayyara-airlangga authored Aug 21, 2023
2 parents 62324f4 + e083ad6 commit 117eda4
Show file tree
Hide file tree
Showing 13 changed files with 260 additions and 134 deletions.
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#!make
include .env

.PHONY: build migrate migrate-down migrate-fix

install:
Expand Down Expand Up @@ -25,10 +28,10 @@ migration:
migrate create -seq -ext sql -dir db/migrations $(filter-out $@,$(MAKECMDGOALS))

migrate:
go run db/migrations/migrate.go $(filter-out $@,$(MAKECMDGOALS))
migrate -path db/migrations -database "postgres://${DB_USER}:${DB_PWD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?${DB_SSL}" up $(filter-out $@,$(MAKECMDGOALS))

migrate-down:
go run db/migrations/migrate.go -action down $(filter-out $@,$(MAKECMDGOALS))
migrate -path db/migrations -database "postgres://${DB_USER}:${DB_PWD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?${DB_SSL}" down $(filter-out $@,$(MAKECMDGOALS))

migrate-fix:
go run db/migrations/migrate.go -action force -version $(filter-out $@,$(MAKECMDGOALS))
migrate -path db/migrations -database "postgres://${DB_USER}:${DB_PWD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?${DB_SSL}" force $(filter-out $@,$(MAKECMDGOALS))
25 changes: 25 additions & 0 deletions app/auth/handlers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package auth

import (
"encoding/json"
"net/http"

"github.com/lexica-app/lexicapi/app"
Expand Down Expand Up @@ -61,3 +62,27 @@ func signInWithGoogleHandler(w http.ResponseWriter, r *http.Request) {

app.WriteHttpBodyJson(w, http.StatusOK, signIn)
}

func onboardUserHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

user, ok := ctx.Value(UserInfoCtx).(User)
if !ok {
app.WriteHttpError(w, http.StatusUnauthorized, ErrInvalidAccessToken)
return
}

var body onboardReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
app.WriteHttpError(w, http.StatusBadRequest, err)
return
}

user, errs, _ := onboardUser(ctx, user, body)
if errs != nil {
app.WriteHttpErrors(w, http.StatusBadRequest, errs)
return
}

app.WriteHttpBodyJson(w, http.StatusOK, user)
}
67 changes: 67 additions & 0 deletions app/auth/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"fmt"

"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
Expand Down Expand Up @@ -136,3 +137,69 @@ func saveAccount(ctx context.Context, tx pgx.Tx, account Account) (newAccount Ac

return newAccount, nil
}

func updateUserForOnboarding(ctx context.Context, tx pgx.Tx, user User, interestIds []ulid.ULID) (onboardedUser User, err error) {
if _, err = findUserById(ctx, tx, user.Id); err != nil {
return
}

q := `
UPDATE users
SET role = $1, education_level = $2, status = $3, updated_at = $4
WHERE id = $5 AND deleted_at IS NULL
RETURNING *
`

if err = pgxscan.Get(
ctx,
tx,
&onboardedUser,
q,
user.Role,
user.EducationLevel,
user.Status,
user.UpdatedAt,
user.Id,
); err != nil {
if err.Error() == "scanning one: no rows in result set" {
return User{}, ErrUserDoesNotExist
}

log.Err(err).Msg("Failed to update user for onboarding")
return
}

q = `
UPDATE users_interests
SET deleted_at = $2
WHERE user_id = $1 AND deleted_at IS NULL
`

_, err = tx.Exec(ctx, q, user.Id, user.UpdatedAt)
if err != nil {
log.Err(err).Msg("Failed to update user for onboarding")
return
}

q = "INSERT INTO users_interests (id, user_id, category_id, created_at) VALUES"

params := []any{user.Id, user.UpdatedAt}
paramCount := 3
for i, interestId := range interestIds {
q += fmt.Sprintf("\n($%d, $1, $%d, $2)", paramCount, paramCount+1)
if i+1 < len(interestIds) {
q += ","
}

params = append(params, ulid.Make(), interestId)
paramCount += 2
}

_, err = tx.Exec(ctx, q, params...)
if err != nil {
log.Err(err).Msg("Failed to update user for onboarding")
return
}

return onboardedUser, nil
}
8 changes: 8 additions & 0 deletions app/auth/request.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package auth

import "github.com/oklog/ulid/v2"

type superadminSignInReq struct {
Email string `json:"email"`
Password string `json:"password"`
}

type onboardReq struct {
Role string `json:"role"`
EducationLevel string `json:"education_level"`
InterestIds []ulid.ULID `json:"interest_ids"`
}
1 change: 1 addition & 0 deletions app/auth/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func Router() *chi.Mux {
r.Use(UserAuthMiddleware)

r.Get("/userinfo", getUserInfoHandler)
r.Put("/onboarding", onboardUserHandler)
})

r.Group(func(r chi.Router) {
Expand Down
35 changes: 31 additions & 4 deletions app/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func signInWithGoogle(ctx context.Context, idToken string) (signIn UserSignIn, e
return
}

account, err = findAccountByProviderAndProviderAccountId(ctx, tx, GOOGLE, accountId)
_, err = findAccountByProviderAndProviderAccountId(ctx, tx, GOOGLE, accountId)
if err != nil {
if err != ErrAccountDoesNotExist {
return
Expand All @@ -77,13 +77,13 @@ func signInWithGoogle(ctx context.Context, idToken string) (signIn UserSignIn, e
return
}

account, err = saveAccount(ctx, tx, account)
_, err = saveAccount(ctx, tx, account)
if err != nil {
return
}
}
} else {
account, err = findAccountByProviderAndProviderAccountId(ctx, tx, GOOGLE, accountId)
_, err = findAccountByProviderAndProviderAccountId(ctx, tx, GOOGLE, accountId)
if err != nil {
if err != ErrAccountDoesNotExist {
return
Expand All @@ -100,7 +100,7 @@ func signInWithGoogle(ctx context.Context, idToken string) (signIn UserSignIn, e
return
}

account, err = saveAccount(ctx, tx, account)
_, err = saveAccount(ctx, tx, account)
if err != nil {
return
}
Expand Down Expand Up @@ -128,3 +128,30 @@ func signInWithGoogle(ctx context.Context, idToken string) (signIn UserSignIn, e
RefreshToken: refreshToken,
}, nil, nil
}

func onboardUser(ctx context.Context, user User, body onboardReq) (User, map[string]error, error) {
errs := user.Onboard(body.Role, body.EducationLevel)
if errs != nil {
return User{}, errs, nil
}

tx, err := pool.Begin(ctx)
if err != nil {
log.Err(err).Msg("Failed to onboard user")
return User{}, nil, err
}

defer tx.Rollback(ctx)

user, err = updateUserForOnboarding(ctx, tx, user, body.InterestIds)
if err != nil {
return User{}, nil, err
}

if err = tx.Commit(ctx); err != nil {
log.Err(err).Msg("Failed to onboard user")
return User{}, nil, err
}

return user, nil, nil
}
77 changes: 69 additions & 8 deletions app/auth/user.go
Original file line number Diff line number Diff line change
@@ -1,29 +1,56 @@
package auth

import (
"errors"
"time"

"github.com/oklog/ulid/v2"
"gopkg.in/guregu/null.v4"
)

var (
ErrUnverifiedUser = errors.New("User must be verified first")
)

type UserStatus uint
type UserRole struct {
null.String
}

type UserEducationLevel struct {
null.String
}

const (
NOT_VERIFIED UserStatus = iota
NOT_ONBOARDED
ACTIVE
)

var (
STUDENT = UserRole{null.StringFrom("pelajar")}
EDUCATOR = UserRole{null.StringFrom("pengajar")}
CIVILIAN = UserRole{null.StringFrom("umum")}
)

var (
SMP = UserEducationLevel{null.StringFrom("smp")}
SMA = UserEducationLevel{null.StringFrom("sma")}
SARJANA = UserEducationLevel{null.StringFrom("sarjana")}
LAINNYA = UserEducationLevel{null.StringFrom("lainnya")}
)

type User struct {
Id ulid.ULID `json:"id"`
Name null.String `json:"name"`
Email null.String `json:"email"`
ImageUrl null.String `json:"image_url"`
Status UserStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt null.Time `json:"updated_at"`
DeletedAt null.Time `json:"deleted_at"`
Id ulid.ULID `json:"id"`
Name null.String `json:"name"`
Email null.String `json:"email"`
ImageUrl null.String `json:"image_url"`
Status UserStatus `json:"status"`
Role UserRole `json:"role"`
EducationLevel UserEducationLevel `json:"education_level"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt null.Time `json:"updated_at"`
DeletedAt null.Time `json:"deleted_at"`
}

func NewUserWithOAuth(name, email, imageUrl null.String) (User, map[string]error) {
Expand Down Expand Up @@ -53,3 +80,37 @@ func NewUserWithOAuth(name, email, imageUrl null.String) (User, map[string]error
CreatedAt: time.Now(),
}, nil
}

func (u *User) Onboard(roleStr, educationLevelStr string) (errs map[string]error) {
errs = make(map[string]error)

if u.Status == ACTIVE {
return nil
}

if u.Status == NOT_VERIFIED {
errs["status"] = ErrUnverifiedUser
return errs
}

role, err := validateUserRole(roleStr)
if err != nil {
errs["role"] = err
}

educationLevel, err := validateUserEducationLevel(educationLevelStr)
if err != nil {
errs["education_level"] = err
}

if len(errs) != 0 {
return errs
}

u.Role = role
u.EducationLevel = educationLevel
u.Status = ACTIVE
u.UpdatedAt = null.TimeFrom(time.Now())

return nil
}
40 changes: 35 additions & 5 deletions app/auth/user_validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import (
)

var (
ErrInvalidUserId = validation.NewError("auth:invalid_user_id", "Invalid user id")
ErrUserNameTooLong = validation.NewError("auth:user_name_too_long", "Name can't be longer than 255 characters")
ErrInvalidUserEmail = validation.NewError("auth:invalid_user_email", "Invalid user email")
ErrInvalidUserImageUrl = validation.NewError("auth:invalid_user_image_url", "Invalid user image url")
ErrInvalidUserStatus = validation.NewError("auth:invalid_user_status", "Invalid user status")
ErrInvalidUserId = validation.NewError("auth:invalid_user_id", "Invalid user id")
ErrUserNameTooLong = validation.NewError("auth:user_name_too_long", "Name can't be longer than 255 characters")
ErrInvalidUserEmail = validation.NewError("auth:invalid_user_email", "Invalid user email")
ErrInvalidUserImageUrl = validation.NewError("auth:invalid_user_image_url", "Invalid user image url")
ErrInvalidUserStatus = validation.NewError("auth:invalid_user_status", "Invalid user status")
ErrInvalidUserRole = validation.NewError("auth:invalid_user_role", "Invalid user role")
ErrInvalidUserEducationlevel = validation.NewError("auth:invalid_user_education_level", "Invalid user education level")
)

func validateUserId(idStr string) (id ulid.ULID, err error) {
Expand Down Expand Up @@ -65,3 +67,31 @@ func validateUserStatus(status int) (err error) {

return nil
}

func validateUserRole(roleStr string) (role UserRole, err error) {
switch roleStr {
case STUDENT.String.String:
return STUDENT, nil
case EDUCATOR.String.String:
return EDUCATOR, nil
case CIVILIAN.String.String:
return CIVILIAN, nil
default:
return UserRole{}, ErrInvalidUserRole
}
}

func validateUserEducationLevel(levelStr string) (level UserEducationLevel, err error) {
switch levelStr {
case SMP.String.String:
return SMP, nil
case SMA.String.String:
return SMA, nil
case SARJANA.String.String:
return SARJANA, nil
case LAINNYA.String.String:
return LAINNYA, nil
default:
return UserEducationLevel{}, ErrInvalidUserRole
}
}
5 changes: 5 additions & 0 deletions db/migrations/000004_onboarding_models.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE IF EXISTS users
DROP COLUMN IF EXISTS role,
DROP COLUMN IF EXISTS education_level;

DROP TABLE IF EXISTS users_interests;
13 changes: 13 additions & 0 deletions db/migrations/000004_onboarding_models.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS users_interests (
id BYTEA NOT NULL,
user_id BYTEA NOT NULL,
category_id BYTEA NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
deleted_at TIMESTAMPTZ,
PRIMARY KEY(id),
CONSTRAINT users_interests_unique UNIQUE NULLS NOT DISTINCT (user_id, category_id, deleted_at)
);

ALTER TABLE IF EXISTS users
ADD COLUMN IF NOT EXISTS role VARCHAR(50),
ADD COLUMN IF NOT EXISTS education_level VARCHAR(50);
Loading

0 comments on commit 117eda4

Please sign in to comment.