Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8479b8ac4b | ||
|
|
4b91e7b32c | ||
|
|
47a6ad6cbf | ||
|
|
2ae4dbdc11 | ||
|
|
8b0d82ac24 | ||
|
|
755ebefd30 | ||
|
|
8ab2f3f1bf | ||
|
|
e52b7d4fad | ||
|
|
5cfeb0bf96 | ||
|
|
2e16eefaba | ||
|
|
491b8da1ac |
@@ -1,55 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type apiResponse struct {
|
|
||||||
Method string `json:"method"`
|
|
||||||
Url string `json:"url"`
|
|
||||||
Proto string `json:"proto"`
|
|
||||||
Header map[string]string `json:"header"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
RemoteAddr string `json:"remoteAddr"`
|
|
||||||
Form map[string]string `json:"form"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultHandler handles the default route
|
|
||||||
//
|
|
||||||
// @Summary Default route handler
|
|
||||||
// @Description Default route handler
|
|
||||||
// @Tags
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} apiResponse
|
|
||||||
// @Router / [get]
|
|
||||||
func DefaultHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
resp := apiResponse{
|
|
||||||
Method: r.Method,
|
|
||||||
Url: r.URL.String(),
|
|
||||||
Proto: r.Proto,
|
|
||||||
Header: make(map[string]string),
|
|
||||||
Host: r.Host,
|
|
||||||
RemoteAddr: r.RemoteAddr,
|
|
||||||
Form: make(map[string]string),
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range r.Header {
|
|
||||||
resp.Header[k] = v[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.ParseForm(); err == nil {
|
|
||||||
for k, v := range r.Form {
|
|
||||||
resp.Form[k] = v[0]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.ErrorContext(ctx, "error parsing form", slog.Any("error", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
response.JSON(ctx, w, http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/robindittmar/dttmr-api/internal/api/response"
|
||||||
|
"github.com/robindittmar/dttmr-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InviteHandler struct {
|
||||||
|
InviteService *domain.InviteService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInviteHandler(inviteService *domain.InviteService) *InviteHandler {
|
||||||
|
return &InviteHandler{InviteService: inviteService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvite handles the creation of an invitation
|
||||||
|
//
|
||||||
|
// @Summary Create invite route
|
||||||
|
// @Description Create an invitation, which may be used to register an account
|
||||||
|
// @Tags Invite
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 201 {object} domain.Invite
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to create invite"
|
||||||
|
// @Router /user/invites [post]
|
||||||
|
func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to create invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invite, err := h.InviteService.CreateInvite(ctx, authContext.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to create invite", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to create invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "created invite successfully", slog.String("invite_id", invite.ID))
|
||||||
|
response.JSON(ctx, w, http.StatusCreated, invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInvite handles the deletion of an invitation
|
||||||
|
//
|
||||||
|
// @Summary Delete invitation route
|
||||||
|
// @Description Deletes an invitation. Invitations can only be deleted when they have not been used
|
||||||
|
// @Tags Invite
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Invite ID"
|
||||||
|
// @Success 204
|
||||||
|
// @Error 400 {object} response.ErrorResponse "failed to decode request url"
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to delete invite"
|
||||||
|
// @Router /user/invites/{id} [delete]
|
||||||
|
func (h *InviteHandler) DeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
inviteID := r.PathValue("id")
|
||||||
|
if inviteID == "" {
|
||||||
|
slog.ErrorContext(ctx, "failed to read invite id from path")
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request url")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.InviteService.DeleteInvite(ctx, authContext.UserID, inviteID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to delete invite", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to delete invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "deleted invite successfully", slog.String("invite_id", inviteID))
|
||||||
|
response.Status(w, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInvites handles fetching the list of a users invitations
|
||||||
|
//
|
||||||
|
// @Summary Get invitations route
|
||||||
|
// @Description Gets a list of all invitations the user has created
|
||||||
|
// @Tags Invite
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} []domain.Invite
|
||||||
|
// @Error 500 {object} response.ErrorResponse "failed to get invites"
|
||||||
|
// @Router /user/invites [get]
|
||||||
|
func (h *InviteHandler) GetInvites(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
authContext, err := domain.GetAuthContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invites, err := h.InviteService.GetInvites(ctx, authContext.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get invites", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to get invites")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.JSON(ctx, w, http.StatusOK, invites)
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "created list successfully", slog.Any("list_id", list.ID))
|
slog.InfoContext(ctx, "created list successfully", slog.String("list_id", list.ID))
|
||||||
response.JSON(ctx, w, http.StatusCreated, list)
|
response.JSON(ctx, w, http.StatusCreated, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ func (h *ListHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "deleted list successfully", slog.Any("list_id", listID))
|
slog.InfoContext(ctx, "deleted list successfully", slog.String("list_id", listID))
|
||||||
response.Status(w, http.StatusNoContent)
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,10 @@ func (h *ListHandler) AddUserToList(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "added user to list successfully", slog.Any("list_id", payload.ListID), slog.Any("email", user.Email))
|
slog.InfoContext(ctx, "added user to list successfully",
|
||||||
|
slog.String("list_id", payload.ListID),
|
||||||
|
slog.String("email", user.Email),
|
||||||
|
)
|
||||||
response.Status(w, http.StatusNoContent)
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +225,10 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "removed user from list successfully", slog.Any("list_id", payload.ListID), slog.Any("email", user.Email))
|
slog.InfoContext(ctx, "removed user from list successfully",
|
||||||
|
slog.String("list_id", payload.ListID),
|
||||||
|
slog.String("email", user.Email),
|
||||||
|
)
|
||||||
response.Status(w, http.StatusNoContent)
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +268,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "created list item successfully", slog.Any("list_item_id", item.ID))
|
slog.InfoContext(ctx, "created list item successfully", slog.String("list_item_id", item.ID))
|
||||||
response.JSON(ctx, w, http.StatusCreated, item)
|
response.JSON(ctx, w, http.StatusCreated, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +308,7 @@ func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "deleted list item successfully", slog.Any("list_item_id", listItemID))
|
slog.InfoContext(ctx, "deleted list item successfully", slog.String("list_item_id", listItemID))
|
||||||
response.Status(w, http.StatusNoContent)
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +349,7 @@ func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "updated list item successfully", slog.Any("list_item_id", payload.ListItemID))
|
slog.InfoContext(ctx, "updated list item successfully", slog.String("list_item_id", payload.ListItemID))
|
||||||
response.Status(w, http.StatusNoContent)
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +399,7 @@ func (h *ListHandler) SetListItemCompleted(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "updated list item completed successful", slog.Any("list_item_id", listItemID))
|
slog.InfoContext(ctx, "updated list item completed successful", slog.String("list_item_id", listItemID))
|
||||||
response.Status(w, http.StatusNoContent)
|
response.Status(w, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
UserService *domain.UserService
|
UserService *domain.UserService
|
||||||
AuthService *domain.AuthService
|
AuthService *domain.AuthService
|
||||||
|
InviteService *domain.InviteService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserHandler(userService *domain.UserService, authService *domain.AuthService) *UserHandler {
|
func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, inviteService *domain.InviteService) *UserHandler {
|
||||||
return &UserHandler{UserService: userService, AuthService: authService}
|
return &UserHandler{UserService: userService, AuthService: authService, InviteService: inviteService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser handles the creation of a user
|
// CreateUser handles the creation of a user
|
||||||
@@ -28,6 +29,7 @@ func NewUserHandler(userService *domain.UserService, authService *domain.AuthSer
|
|||||||
// @Param payload body request.CreateUserPayload true "Create user payload"
|
// @Param payload body request.CreateUserPayload true "Create user payload"
|
||||||
// @Success 201 {object} domain.User
|
// @Success 201 {object} domain.User
|
||||||
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
|
||||||
|
// @Error 400 {object} response.ErrorResponse "invite is invalid"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to create user"
|
// @Error 500 {object} response.ErrorResponse "failed to create user"
|
||||||
// @Router /users [post]
|
// @Router /users [post]
|
||||||
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -40,6 +42,15 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invite, err := h.InviteService.GetInvite(ctx, payload.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to get invite", slog.Any("error", err))
|
||||||
|
response.Error(ctx, w, http.StatusBadRequest, "invite is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Creating user and consuming the invite must be in a transaction.
|
||||||
|
// The repositories must support tx in context, and the handler must be able to start a transaction
|
||||||
user, err := h.UserService.CreateUser(ctx, payload.Email, payload.Name, payload.Password)
|
user, err := h.UserService.CreateUser(ctx, payload.Email, payload.Name, payload.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err))
|
slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err))
|
||||||
@@ -47,7 +58,26 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.InfoContext(ctx, "created user successfully", slog.Any("user_id", user.ID))
|
err = h.InviteService.ConsumeInvite(ctx, invite.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to consume invite", slog.Any("error", err))
|
||||||
|
|
||||||
|
// TODO: This should be a transaction rollback, once we have db transactions in the handler
|
||||||
|
err = h.UserService.DeleteUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "failed to delete user again",
|
||||||
|
slog.Any("error", err),
|
||||||
|
slog.String("user_id", user.ID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
response.Error(ctx, w, http.StatusInternalServerError, "failed to consume invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "created user successfully",
|
||||||
|
slog.String("user_id", user.ID),
|
||||||
|
slog.String("invite_id", invite.ID),
|
||||||
|
)
|
||||||
response.JSON(ctx, w, http.StatusCreated, user)
|
response.JSON(ctx, w, http.StatusCreated, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +94,7 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Error 401 {object} response.ErrorResponse "could not authenticate with current password"
|
// @Error 401 {object} response.ErrorResponse "could not authenticate with current password"
|
||||||
// @Error 500 {object} response.ErrorResponse "could not get auth context"
|
// @Error 500 {object} response.ErrorResponse "could not get auth context"
|
||||||
// @Error 500 {object} response.ErrorResponse "failed to change password"
|
// @Error 500 {object} response.ErrorResponse "failed to change password"
|
||||||
// @Router /users/password [post]
|
// @Router /user/password [post]
|
||||||
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package request
|
package request
|
||||||
|
|
||||||
type CreateUserPayload struct {
|
type CreateUserPayload struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
InviteCode string `json:"invite_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangePasswordPayload struct {
|
type ChangePasswordPayload struct {
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ func NewMux(cfg Config) http.Handler {
|
|||||||
authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret))
|
authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret))
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
|
|
||||||
|
inviteRepo := repository.NewInviteRepo(cfg.Database)
|
||||||
|
inviteService := domain.NewInviteService(inviteRepo)
|
||||||
|
inviteHandler := handler.NewInviteHandler(inviteService)
|
||||||
|
|
||||||
userRepo := repository.NewUserRepo(cfg.Database)
|
userRepo := repository.NewUserRepo(cfg.Database)
|
||||||
userService := domain.NewUserService(userRepo)
|
userService := domain.NewUserService(userRepo)
|
||||||
userHandler := handler.NewUserHandler(userService, authService)
|
userHandler := handler.NewUserHandler(userService, authService, inviteService)
|
||||||
|
|
||||||
listRepo := repository.NewListRepo(cfg.Database)
|
listRepo := repository.NewListRepo(cfg.Database)
|
||||||
listService := domain.NewListService(listRepo)
|
listService := domain.NewListService(listRepo)
|
||||||
@@ -31,17 +35,26 @@ func NewMux(cfg Config) http.Handler {
|
|||||||
protected := middleware.WithJWT(authService)
|
protected := middleware.WithJWT(authService)
|
||||||
|
|
||||||
apiMux := http.NewServeMux()
|
apiMux := http.NewServeMux()
|
||||||
apiMux.HandleFunc("/", handler.DefaultHandler)
|
|
||||||
apiMux.HandleFunc("GET /health", handler.HealthHandler)
|
apiMux.HandleFunc("GET /health", handler.HealthHandler)
|
||||||
|
|
||||||
|
// Auth
|
||||||
apiMux.HandleFunc("POST /login", authHandler.Login)
|
apiMux.HandleFunc("POST /login", authHandler.Login)
|
||||||
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
|
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
|
||||||
apiMux.HandleFunc("POST /logout", authHandler.Logout)
|
apiMux.HandleFunc("POST /logout", authHandler.Logout)
|
||||||
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices)
|
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices)
|
||||||
|
|
||||||
apiMux.Handle("POST /users", protected(userHandler.CreateUser))
|
// Users
|
||||||
apiMux.Handle("POST /users/password", protected(userHandler.ChangePassword))
|
apiMux.HandleFunc("POST /users", userHandler.CreateUser)
|
||||||
|
|
||||||
|
// User
|
||||||
|
apiMux.Handle("POST /user/password", protected(userHandler.ChangePassword))
|
||||||
|
|
||||||
|
// Invites
|
||||||
|
apiMux.Handle("POST /user/invites", protected(inviteHandler.CreateInvite))
|
||||||
|
apiMux.Handle("DELETE /user/invites/{id}", protected(inviteHandler.DeleteInvite))
|
||||||
|
apiMux.Handle("GET /user/invites", protected(inviteHandler.GetInvites))
|
||||||
|
|
||||||
|
// Lists
|
||||||
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
|
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
|
||||||
apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList))
|
apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList))
|
||||||
apiMux.Handle("GET /lists", protected(listHandler.GetLists))
|
apiMux.Handle("GET /lists", protected(listHandler.GetLists))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS invites (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
inviter_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
inviter_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
invitee_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
invitee_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
code VARCHAR(128) NOT NULL,
|
code VARCHAR(64) NOT NULL,
|
||||||
consumed_at TIMESTAMPTZ,
|
consumed_at TIMESTAMPTZ,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInviteIDMissing = errors.New("invite id is required")
|
||||||
|
ErrCodeMissing = errors.New("invite code is required")
|
||||||
|
ErrInviteExpired = errors.New("invite is expired")
|
||||||
|
ErrInviteConsumed = errors.New("invite is already consumed")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Invite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
ConsumedAt *time.Time `json:"consumed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteRepository interface {
|
||||||
|
CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*Invite, error)
|
||||||
|
DeleteInvite(ctx context.Context, userID string, inviteID string) error
|
||||||
|
ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error
|
||||||
|
GetInvite(ctx context.Context, code string) (*Invite, error)
|
||||||
|
GetInvites(ctx context.Context, userID string) ([]Invite, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteService struct {
|
||||||
|
repo InviteRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInviteService(r InviteRepository) *InviteService {
|
||||||
|
return &InviteService{repo: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) CreateInvite(ctx context.Context, inviterUserID string) (*Invite, error) {
|
||||||
|
if inviterUserID == "" {
|
||||||
|
return nil, ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generateSecureToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
code := hashToken(token)
|
||||||
|
expiresAt := time.Now().Add(time.Hour * 24 * 7)
|
||||||
|
|
||||||
|
return s.repo.CreateInvite(ctx, inviterUserID, code, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) DeleteInvite(ctx context.Context, userID string, inviteID string) error {
|
||||||
|
if userID == "" {
|
||||||
|
return ErrUserIDMissing
|
||||||
|
}
|
||||||
|
if inviteID == "" {
|
||||||
|
return ErrInviteIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.DeleteInvite(ctx, userID, inviteID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error {
|
||||||
|
if inviteeUserID == "" {
|
||||||
|
return ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.ConsumeInvite(ctx, inviteID, inviteeUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) GetInvite(ctx context.Context, code string) (*Invite, error) {
|
||||||
|
if code == "" {
|
||||||
|
return nil, ErrCodeMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
invite, err := s.repo.GetInvite(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if invite.ExpiresAt.Before(time.Now()) {
|
||||||
|
return nil, ErrInviteExpired
|
||||||
|
}
|
||||||
|
if invite.ConsumedAt != nil {
|
||||||
|
return nil, ErrInviteConsumed
|
||||||
|
}
|
||||||
|
|
||||||
|
return invite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InviteService) GetInvites(ctx context.Context, userID string) ([]Invite, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return nil, ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.GetInvites(ctx, userID)
|
||||||
|
}
|
||||||
+10
-1
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUserIDMissing = errors.New("user id is missing")
|
ErrUserIDMissing = errors.New("user id is required")
|
||||||
ErrEmailMissing = errors.New("email is required")
|
ErrEmailMissing = errors.New("email is required")
|
||||||
ErrNameMissing = errors.New("name is required")
|
ErrNameMissing = errors.New("name is required")
|
||||||
ErrPasswordMissing = errors.New("password is required")
|
ErrPasswordMissing = errors.New("password is required")
|
||||||
@@ -24,6 +24,7 @@ type User struct {
|
|||||||
|
|
||||||
type UserRepository interface {
|
type UserRepository interface {
|
||||||
CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error)
|
CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error)
|
||||||
|
DeleteUser(ctx context.Context, userID string) error
|
||||||
ChangePassword(ctx context.Context, userID string, passwordHash string) error
|
ChangePassword(ctx context.Context, userID string, passwordHash string) error
|
||||||
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,14 @@ func (s *UserService) CreateUser(ctx context.Context, email string, name string,
|
|||||||
return s.repo.CreateUser(ctx, email, name, string(hash))
|
return s.repo.CreateUser(ctx, email, name, string(hash))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UserService) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
if len(userID) == 0 {
|
||||||
|
return ErrUserIDMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.DeleteUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UserService) ChangePassword(ctx context.Context, userID string, password string) error {
|
func (s *UserService) ChangePassword(ctx context.Context, userID string, password string) error {
|
||||||
if len(userID) == 0 {
|
if len(userID) == 0 {
|
||||||
return ErrUserIDMissing
|
return ErrUserIDMissing
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robindittmar/dttmr-api/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InviteRepo struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInviteRepo(db *sql.DB) *InviteRepo {
|
||||||
|
return &InviteRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*domain.Invite, error) {
|
||||||
|
var id string
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
"INSERT INTO invites (inviter_user_id, code, expires_at) VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
inviterUserID, code, expiresAt,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to insert invites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.Invite{
|
||||||
|
ID: id,
|
||||||
|
Code: code,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
ConsumedAt: nil,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) DeleteInvite(ctx context.Context, userID string, inviteID string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
"DELETE FROM invites WHERE id = $1 AND inviter_user_id = $2 AND consumed_at IS NULL",
|
||||||
|
inviteID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete invite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error {
|
||||||
|
res, err := r.db.ExecContext(ctx,
|
||||||
|
"UPDATE invites SET invitee_user_id=$1, consumed_at=NOW() WHERE id=$2 AND expires_at > NOW() AND consumed_at IS NULL",
|
||||||
|
inviteeUserID, inviteID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update invite: %w", err)
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get rows affected: %w", err)
|
||||||
|
}
|
||||||
|
if affected < 1 {
|
||||||
|
return fmt.Errorf("invite not found or expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) GetInvite(ctx context.Context, code string) (*domain.Invite, error) {
|
||||||
|
var invite domain.Invite
|
||||||
|
|
||||||
|
err := r.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id, code, expires_at, consumed_at FROM invites WHERE code=$1",
|
||||||
|
code,
|
||||||
|
).Scan(&invite.ID, &invite.Code, &invite.ExpiresAt, &invite.ConsumedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get invite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &invite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InviteRepo) GetInvites(ctx context.Context, userID string) ([]domain.Invite, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
"SELECT id, code, expires_at, consumed_at FROM invites WHERE inviter_user_id=$1",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get invites: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var invites []domain.Invite
|
||||||
|
for rows.Next() {
|
||||||
|
var i domain.Invite
|
||||||
|
err = rows.Scan(&i.ID, &i.Code, &i.ExpiresAt, &i.ConsumedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
invites = append(invites, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return invites, nil
|
||||||
|
}
|
||||||
@@ -93,7 +93,6 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return lists, nil
|
return lists, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error {
|
func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error {
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, pa
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *UserRepo) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
"DELETE FROM users WHERE id = $1",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHash string) error {
|
func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHash string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
"UPDATE users SET password_hash = $1 WHERE id = $2",
|
"UPDATE users SET password_hash = $1 WHERE id = $2",
|
||||||
|
|||||||
Reference in New Issue
Block a user