Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,33 @@ import (
"log/slog"
"net/http"
"os"

"os/signal"
"syscall"
"time"

"github.com/joshsoftware/code-curiosity-2025/internal/app"
"github.com/joshsoftware/code-curiosity-2025/internal/config"
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/jobs"
)

func main() {
ctx := context.Background()

cfg,err := config.LoadAppConfig()
cfg, err := config.LoadAppConfig()
if err != nil {
slog.Error("error loading app config", "error", err)
return
}


db, err := config.InitDataStore(cfg)
if err != nil {
slog.Error("error initializing database", "error", err)
return
}
defer db.Close()

dependencies := app.InitDependencies(db,cfg)
dependencies := app.InitDependencies(db, cfg)

router := app.NewRouter(dependencies)

Expand All @@ -41,6 +41,9 @@ func main() {
Handler: router,
}

// backround job start
jobs.PermanentDeleteJob(db)

serverRunning := make(chan os.Signal, 1)

signal.Notify(
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/robfig/cron/v3 v3.0.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
Expand Down
11 changes: 11 additions & 0 deletions internal/app/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"context"
"encoding/json"
"fmt"
"log/slog"

"github.com/joshsoftware/code-curiosity-2025/internal/app/user"
Expand Down Expand Up @@ -83,6 +84,16 @@ func (s *service) GithubOAuthLoginCallback(ctx context.Context, code string) (st
return "", apperrors.ErrInternalServer
}

// soft delete checker
err = s.userService.RecoverAccountInGracePeriod(ctx, userData.Id)
if err != nil {
slog.Error("error in recovering account in grace period during login", "error", err)
return "", apperrors.ErrInternalServer
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only call recover if userData.IsDeleted is true

// token print

fmt.Println(jwtToken)

return jwtToken, nil
}

Expand Down
2 changes: 2 additions & 0 deletions internal/app/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,7 @@ func NewRouter(deps Dependencies) http.Handler {

router.HandleFunc("PATCH /api/v1/user/email", middleware.Authentication(deps.UserHandler.UpdateUserEmail, deps.AppCfg))

router.HandleFunc("DELETE /api/user/delete", middleware.Authentication(deps.UserHandler.DeleteUser, deps.AppCfg))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally in HTTP routes, we keep id in both token and path, and in handler we check if both are equal. So the path would be DELETE /api/user/delete/{user_id}


return middleware.CorsMiddleware(router, deps.AppCfg)
}
4 changes: 2 additions & 2 deletions internal/app/user/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ type User struct {
Password string `json:"password"`
IsDeleted bool `json:"is_deleted"`
DeletedAt sql.NullTime `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type CreateUserRequestBody struct {
Expand Down
20 changes: 20 additions & 0 deletions internal/app/user/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"

"github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors"
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware"
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/response"
)

Expand All @@ -15,6 +16,7 @@ type handler struct {

type Handler interface {
UpdateUserEmail(w http.ResponseWriter, r *http.Request)
DeleteUser(w http.ResponseWriter, r *http.Request)
}

func NewHandler(userService Service) Handler {
Expand Down Expand Up @@ -44,3 +46,21 @@ func (h *handler) UpdateUserEmail(w http.ResponseWriter, r *http.Request) {

response.WriteJson(w, http.StatusOK, "email updated successfully", nil)
}

func (h *handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
val := ctx.Value(middleware.UserIdKey)

userID := val.(int)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could extract this logic into a util function, so it can be reused in other handlers


user, err := h.userService.SoftDeleteUser(ctx, userID)
if err != nil {
slog.Error("failed to softdelete user", "error", err)
status, errorMessage := apperrors.MapError(err)
response.WriteJson(w, status, errorMessage, nil)
return
}

response.WriteJson(w, http.StatusOK, "user scheduled for deletion", user)

}
22 changes: 22 additions & 0 deletions internal/app/user/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package user
import (
"context"
"log/slog"
"time"

"github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors"
"github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware"
Expand All @@ -18,6 +19,8 @@ type Service interface {
GetUserByGithubId(ctx context.Context, githubId int) (User, error)
CreateUser(ctx context.Context, userInfo CreateUserRequestBody) (User, error)
UpdateUserEmail(ctx context.Context, email string) error
SoftDeleteUser(ctx context.Context, userID int) (User, error)
RecoverAccountInGracePeriod(ctx context.Context, userID int) error
}

func NewService(userRepository repository.UserRepository) Service {
Expand Down Expand Up @@ -74,3 +77,22 @@ func (s *service) UpdateUserEmail(ctx context.Context, email string) error {

return nil
}

func (s *service) SoftDeleteUser(ctx context.Context, userID int) (User, error) {
now := time.Now()
user, err := s.userRepository.MarkUserAsDeleted(ctx, nil, userID, now)
if err != nil {
slog.Error("unable to softdelete user", "error", err)
return User{}, apperrors.ErrInternalServer
}
return User(user), nil
}

func (s *service) RecoverAccountInGracePeriod(ctx context.Context, userID int) error {
err := s.userRepository.AccountScheduledForDelete(ctx, nil, userID)
if err != nil {
slog.Error("failed to recover account in grace period", "error", err)
return err
}
return nil
}
30 changes: 30 additions & 0 deletions internal/pkg/jobs/cleanUp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package jobs

import (
"log/slog"

"github.com/jmoiron/sqlx"
"github.com/joshsoftware/code-curiosity-2025/internal/repository"
"github.com/robfig/cron/v3"
)

func PermanentDeleteJob(db *sqlx.DB) {
slog.Info("entering into the cleanup job")
c := cron.New()
_, err := c.AddFunc("36 00 * * *", func() {
slog.Info("Job scheduled for user cleanup from database")
ur := repository.NewUserRepository(db) // pass in *sql.DB or whatever is needed
err := ur.DeleteUser(nil)
if err != nil {
slog.Error("Cleanup job error", "error", err)
} else {
slog.Info("User cleanup Job completed.")
}
})

if err != nil {
slog.Error("failed to start user delete job ", "error", err)
}

c.Start()
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we explore different patterns for structure Jobs in Golang, maybe look for things using struct and struct embedding with a Common struct.

78 changes: 78 additions & 0 deletions internal/repository/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type UserRepository interface {
GetUserByGithubId(ctx context.Context, tx *sqlx.Tx, githubId int) (User, error)
CreateUser(ctx context.Context, tx *sqlx.Tx, userInfo CreateUserRequestBody) (User, error)
UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, userId int, email string) error
MarkUserAsDeleted(ctx context.Context, tx *sqlx.Tx, userID int, deletedAt time.Time) (User, error)
AccountScheduledForDelete(ctx context.Context, tx *sqlx.Tx, userID int) error
DeleteUser(tx *sqlx.Tx) error
}

func NewUserRepository(db *sqlx.DB) UserRepository {
Expand Down Expand Up @@ -120,6 +123,8 @@ func (ur *userRepository) CreateUser(ctx context.Context, tx *sqlx.Tx, userInfo
userInfo.GithubUsername,
userInfo.Email,
userInfo.AvatarUrl,
time.Now(),
time.Now(),
).Scan(
&user.Id,
&user.GithubId,
Expand Down Expand Up @@ -156,3 +161,76 @@ func (ur *userRepository) UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, user

return nil
}

func (ur *userRepository) MarkUserAsDeleted(ctx context.Context, tx *sqlx.Tx, userID int, deletedAt time.Time) (User, error) {
executer := ur.BaseRepository.initiateQueryExecuter(tx)
_, err := executer.ExecContext(ctx, `UPDATE users SET is_deleted = TRUE, deleted_at=$1 WHERE id = $2`, deletedAt, userID)
if err != nil {
slog.Error("unable to mark user as deleted", "error", err)
return User{}, apperrors.ErrInternalServer
}
var user User
err = executer.QueryRowContext(ctx, getUserByIdQuery, userID).Scan(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use GetUserById instead

&user.Id,
&user.GithubId,
&user.GithubUsername,
&user.AvatarUrl,
&user.Email,
&user.CurrentActiveGoalId,
&user.CurrentBalance,
&user.IsBlocked,
&user.IsAdmin,
&user.Password,
&user.IsDeleted,
&user.DeletedAt,
&user.CreatedAt,
&user.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
slog.Error("user not found", "error", err)
return User{}, apperrors.ErrUserNotFound
}
slog.Error("error occurred while getting user by id", "error", err)
return User{}, apperrors.ErrInternalServer
}
return user, nil
}

func (ur *userRepository) AccountScheduledForDelete(ctx context.Context, tx *sqlx.Tx, userID int) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic seems correct, but such logic should be in service layer instead of repo

var deleteGracePeriod = 90 * 24 * time.Hour
user, err := ur.GetUserById(ctx, tx, userID)

if err != nil {
slog.Error("unable to fetch user by ID ", "error", err)
return apperrors.ErrInternalServer
}

if user.IsDeleted {
var dlt_at time.Time
if !user.DeletedAt.Valid {
return errors.New("invalid deletion state")
} else {
dlt_at = user.DeletedAt.Time
}

if time.Since(dlt_at) >= deleteGracePeriod {
slog.Error("user is permanentaly deleted ", "error", err)
return apperrors.ErrInternalServer
} else {
executer := ur.BaseRepository.initiateQueryExecuter(tx)
_, err := executer.ExecContext(ctx, `UPDATE users SET is_deleted = false, deleted_at = NULL WHERE id = $1`, userID)
slog.Error("unable to reverse the soft delete ", "error", err)
return apperrors.ErrInternalServer
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing err check

}
}
return nil
}

func (ur *userRepository) DeleteUser(tx *sqlx.Tx) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename function

threshold := time.Now().Add(-90 * 1 * time.Second)
executer := ur.BaseRepository.initiateQueryExecuter(tx)
ctx := context.Background()
_, err := executer.ExecContext(ctx, `DELETE FROM users WHERE is_deleted = TRUE AND deleted_at <= $1 `, threshold)
return err
}