Invite system and user registration #32

Merged
robin merged 13 commits from dev into main 2026-08-31 21:38:47 +02:00
6 changed files with 366 additions and 10 deletions
Showing only changes of commit 5cfeb0bf96 - Show all commits
+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)
}
+21 -3
View File
@@ -12,10 +12,11 @@ import (
type UserHandler struct {
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()
+1
View File
@@ -4,6 +4,7 @@ type CreateUserPayload struct {
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))
+98
View File
@@ -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)
}
+109
View File
@@ -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
}