diff --git a/internal/api/handler/invite.go b/internal/api/handler/invite.go new file mode 100644 index 0000000..92b47e8 --- /dev/null +++ b/internal/api/handler/invite.go @@ -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) +} diff --git a/internal/api/handler/user.go b/internal/api/handler/user.go index cc29fa6..f281090 100644 --- a/internal/api/handler/user.go +++ b/internal/api/handler/user.go @@ -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() diff --git a/internal/api/request/user.go b/internal/api/request/user.go index fef44d6..fa794ec 100644 --- a/internal/api/request/user.go +++ b/internal/api/request/user.go @@ -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 { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index a23e28f..de1607c 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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)) diff --git a/internal/domain/invite.go b/internal/domain/invite.go new file mode 100644 index 0000000..3ca7076 --- /dev/null +++ b/internal/domain/invite.go @@ -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) +} diff --git a/internal/repository/invite.go b/internal/repository/invite.go new file mode 100644 index 0000000..bcf35c6 --- /dev/null +++ b/internal/repository/invite.go @@ -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 invitee_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 invitee_user_id=$1", userID, + 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 +}