feat: added invite system

This commit is contained in:
2026-08-31 20:06:51 +02:00
parent 2e16eefaba
commit 5cfeb0bf96
6 changed files with 366 additions and 10 deletions
+118
View File
@@ -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 /users/invite [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 /users/invite/{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 /users/invite [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)
}
+23 -5
View File
@@ -10,12 +10,13 @@ import (
)
type UserHandler struct {
UserService *domain.UserService
AuthService *domain.AuthService
UserService *domain.UserService
AuthService *domain.AuthService
InviteService *domain.InviteService
}
func NewUserHandler(userService *domain.UserService, authService *domain.AuthService) *UserHandler {
return &UserHandler{UserService: userService, AuthService: authService}
func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, inviteService *domain.InviteService) *UserHandler {
return &UserHandler{UserService: userService, AuthService: authService, InviteService: inviteService}
}
// 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"
// @Success 201 {object} domain.User
// @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"
// @Router /users [post]
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
}
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)
if err != nil {
slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err))
@@ -47,6 +58,13 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
return
}
err = h.InviteService.ConsumeInvite(ctx, invite.ID, user.ID)
if err != nil {
slog.ErrorContext(ctx, "failed to consume invite", slog.Any("error", err))
// TODO: As long as this is not executed within a transaction, the user will still be created,
// so we can actually return the success JSON
}
slog.InfoContext(ctx, "created user successfully", slog.Any("user_id", user.ID))
response.JSON(ctx, w, http.StatusCreated, user)
}
@@ -64,7 +82,7 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
// @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 "failed to change password"
// @Router /users/password [post]
// @Router /user/password [post]
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+4 -3
View File
@@ -1,9 +1,10 @@
package request
type CreateUserPayload struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
InviteCode string `json:"invite_code"`
}
type ChangePasswordPayload struct {
+14 -2
View File
@@ -20,9 +20,13 @@ func NewMux(cfg Config) http.Handler {
authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret))
authHandler := handler.NewAuthHandler(authService)
inviteRepo := repository.NewInviteRepo(cfg.Database)
inviteService := domain.NewInviteService(inviteRepo)
inviteHandler := handler.NewInviteHandler(inviteService)
userRepo := repository.NewUserRepo(cfg.Database)
userService := domain.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService, authService)
userHandler := handler.NewUserHandler(userService, authService, inviteService)
listRepo := repository.NewListRepo(cfg.Database)
listService := domain.NewListService(listRepo)
@@ -34,14 +38,22 @@ func NewMux(cfg Config) http.Handler {
apiMux.HandleFunc("/", handler.DefaultHandler)
apiMux.HandleFunc("GET /health", handler.HealthHandler)
// Auth
apiMux.HandleFunc("POST /login", authHandler.Login)
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
apiMux.HandleFunc("POST /logout", authHandler.Logout)
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices)
// Users
apiMux.Handle("POST /users", protected(userHandler.CreateUser))
apiMux.Handle("POST /users/password", protected(userHandler.ChangePassword))
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.CreateInvite))
// Lists
apiMux.Handle("POST /lists", protected(listHandler.CreateList))
apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList))
apiMux.Handle("GET /lists", protected(listHandler.GetLists))