diff --git a/backend/internal/handler/clerk.go b/backend/internal/handler/clerk.go index 55991ae8f..d5e73c4e4 100644 --- a/backend/internal/handler/clerk.go +++ b/backend/internal/handler/clerk.go @@ -1,19 +1,23 @@ package handler import ( + "errors" + "log/slog" "net/http" "github.com/generate/selfserve/config" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/generate/selfserve/internal/utils" "github.com/gofiber/fiber/v2" svix "github.com/svix/svix-webhooks/go" ) type ClerkWebHookHandler struct { - UsersRepository storage.UsersRepository - WebhookVerifier WebhookVerifier + UsersRepository storage.UsersRepository + HotelsRepository storage.HotelsRepository + WebhookVerifier WebhookVerifier } type WebhookVerifier interface { @@ -24,34 +28,77 @@ func NewWebhookVerifier(cfg *config.Config) (WebhookVerifier, error) { return svix.NewWebhook(cfg.Clerk.WebhookSignature) } -func NewClerkWebHookHandler(userRepo storage.UsersRepository, WebhookVerifier WebhookVerifier) *ClerkWebHookHandler { - return &ClerkWebHookHandler{UsersRepository: userRepo, WebhookVerifier: WebhookVerifier} +func NewClerkWebHookHandler(userRepo storage.UsersRepository, hotelsRepo storage.HotelsRepository, webhookVerifier WebhookVerifier) *ClerkWebHookHandler { + return &ClerkWebHookHandler{ + UsersRepository: userRepo, + HotelsRepository: hotelsRepo, + WebhookVerifier: webhookVerifier, + } } -func (h *ClerkWebHookHandler) CreateUser(c *fiber.Ctx) error { +func (h *ClerkWebHookHandler) verifySvix(c *fiber.Ctx) error { headers := http.Header{} headers.Set("svix-id", c.Get("svix-id")) headers.Set("svix-timestamp", c.Get("svix-timestamp")) headers.Set("svix-signature", c.Get("svix-signature")) - - err := h.WebhookVerifier.Verify(c.Body(), headers) - if err != nil { + if err := h.WebhookVerifier.Verify(c.Body(), headers); err != nil { return errs.Unauthorized() } + return nil +} + +func (h *ClerkWebHookHandler) CreateOrgMembership(c *fiber.Ctx) error { + if err := h.verifySvix(c); err != nil { + return err + } - var CreateUserRequest models.CreateUserWebhook - if err := c.BodyParser(&CreateUserRequest); err != nil { + var payload models.CreateUserOrgMembershipWebhook + if err := c.BodyParser(&payload); err != nil { return errs.InvalidJSON() } - clerkUser := &CreateUserRequest.ClerkUser - if err := ValidateCreateUserClerk(clerkUser); err != nil { + + hotel, err := h.HotelsRepository.FindByID(c.Context(), payload.Data.Organization.ID) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("hotel", "id", payload.Data.Organization.ID) + } + return errs.InternalServerError() + } + + userData := &payload.Data.PublicUserData + _, err = h.UsersRepository.InsertUser(c.Context(), ReformatOrgMembershipUserData(userData, hotel.ID)) + if err != nil { + return errs.InternalServerError() + } + + return c.SendStatus(fiber.StatusOK) +} + +// When a new org is created in Clerk, we create a corresponding hotel in our DB +func (h *ClerkWebHookHandler) OrgCreated(c *fiber.Ctx) error { + if err := h.verifySvix(c); err != nil { return err } - res, err := h.UsersRepository.InsertUser(c.Context(), ReformatUserData(clerkUser)) + var payload models.CreateOrgWebhook + if err := c.BodyParser(&payload); err != nil { + return errs.InvalidJSON() + } + + hotel, err := h.HotelsRepository.InsertHotel(c.Context(), &models.CreateHotelRequest{ + ID: payload.Data.ID, + Name: payload.Data.Name, + }) if err != nil { + if errors.Is(err, errs.ErrAlreadyExistsInDB) { + return c.SendStatus(fiber.StatusOK) + } return errs.InternalServerError() } - return c.JSON(res) + if err := utils.UpdateOrgMetadata(c.Context(), payload.Data.ID, hotel.ID); err != nil { + slog.Error("failed to update org metadata", "clerk_org_id", payload.Data.ID, "error", err) + } + + return c.SendStatus(fiber.StatusOK) } diff --git a/backend/internal/handler/hotels.go b/backend/internal/handler/hotels.go index fe2806cf0..5937cfff6 100644 --- a/backend/internal/handler/hotels.go +++ b/backend/internal/handler/hotels.go @@ -4,12 +4,12 @@ import ( "context" "errors" "log/slog" + "strings" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/httpx" "github.com/generate/selfserve/internal/models" "github.com/gofiber/fiber/v2" - "github.com/google/uuid" ) // HotelRepository defines methods for hotel data access @@ -40,10 +40,8 @@ func NewHotelsHandler(repo HotelsRepository) *HotelsHandler { func (h *HotelsHandler) GetHotelByID(c *fiber.Ctx) error { idParam := c.Params("id") - // Validate UUID - _, err := uuid.Parse(idParam) - if err != nil { - return errs.BadRequest("invalid hotel id format") + if strings.TrimSpace(idParam) == "" { + return errs.BadRequest("hotel id is required") } // Fetch hotel diff --git a/backend/internal/handler/hotels_test.go b/backend/internal/handler/hotels_test.go index ddd9dd7d5..46261332d 100644 --- a/backend/internal/handler/hotels_test.go +++ b/backend/internal/handler/hotels_test.go @@ -16,7 +16,6 @@ import ( "github.com/stretchr/testify/require" ) -// Mock repository type mockHotelsRepository struct { findByIDFunc func(ctx context.Context, id string) (*models.Hotel, error) insertHotelFunc func(ctx context.Context, req *models.CreateHotelRequest) (*models.Hotel, error) @@ -38,49 +37,28 @@ func TestHotelHandler_GetHotelByID(t *testing.T) { mock := &mockHotelsRepository{ findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + floors := 10 return &models.Hotel{ - ID: id, CreateHotelRequest: models.CreateHotelRequest{ + ID: id, Name: "Test Hotel", - Floors: 10, + Floors: &floors, }, }, nil }, - insertHotelFunc: nil, // unused for this test } app := fiber.New() h := NewHotelsHandler(mock) app.Get("/hotels/:id", h.GetHotelByID) - req := httptest.NewRequest("GET", "/hotels/123e4567-e89b-12d3-a456-426614174000", nil) + req := httptest.NewRequest("GET", "/hotels/org_2abc123", nil) resp, err := app.Test(req) require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) }) - t.Run("returns 400 on invalid UUID", func(t *testing.T) { - t.Parallel() - - mock := &mockHotelsRepository{ - findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { - return nil, nil - }, - insertHotelFunc: nil, - } - - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(mock) - app.Get("/hotels/:id", h.GetHotelByID) - - req := httptest.NewRequest("GET", "/hotels/invalid-uuid", nil) - resp, err := app.Test(req) - require.NoError(t, err) - - assert.Equal(t, 400, resp.StatusCode) - }) - t.Run("returns 404 when hotel not found", func(t *testing.T) { t.Parallel() @@ -88,14 +66,13 @@ func TestHotelHandler_GetHotelByID(t *testing.T) { findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { return nil, errs.ErrNotFoundInDB }, - insertHotelFunc: nil, } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) h := NewHotelsHandler(mock) app.Get("/hotels/:id", h.GetHotelByID) - req := httptest.NewRequest("GET", "/hotels/123e4567-e89b-12d3-a456-426614174000", nil) + req := httptest.NewRequest("GET", "/hotels/org_2abc123", nil) resp, err := app.Test(req) require.NoError(t, err) @@ -105,28 +82,31 @@ func TestHotelHandler_GetHotelByID(t *testing.T) { func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() + + floors := 10 validBody := `{ + "id": "org_2abc123", "name": "The Grand Budapest Hotel", "floors": 10 }` - t.Run("returns 201 on success", func(t *testing.T) { - t.Parallel() - - mock := &mockHotelsRepository{ + newMock := func(returnFloors *int) *mockHotelsRepository { + return &mockHotelsRepository{ insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { return &models.Hotel{ - ID: "generated-uuid", CreatedAt: time.Now(), UpdatedAt: time.Now(), CreateHotelRequest: *hotel, }, nil }, - findByIDFunc: nil, } + } + + t.Run("returns 201 on success", func(t *testing.T) { + t.Parallel() app := fiber.New() - h := NewHotelsHandler(mock) + h := NewHotelsHandler(newMock(&floors)) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(validBody)) @@ -137,7 +117,7 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { assert.Equal(t, 201, resp.StatusCode) body, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(body), "generated-uuid") + assert.Contains(t, string(body), "org_2abc123") assert.Contains(t, string(body), "The Grand Budapest Hotel") assert.Contains(t, string(body), "10") }) @@ -145,19 +125,8 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Run("returns 400 on invalid JSON", func(t *testing.T) { t.Parallel() - mock := &mockHotelsRepository{ - insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - return &models.Hotel{ - ID: "generated-uuid", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - CreateHotelRequest: *hotel, - }, nil - }, - } - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(mock) + h := NewHotelsHandler(newMock(nil)) app.Post("/hotels", h.CreateHotel) req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{invalid json`)) @@ -168,29 +137,17 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { assert.Equal(t, 400, resp.StatusCode) }) - t.Run("returns 400 on missing required name field", func(t *testing.T) { + t.Run("returns 400 on missing id", func(t *testing.T) { t.Parallel() - mock := &mockHotelsRepository{ - insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - return &models.Hotel{ - ID: "generated-uuid", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - CreateHotelRequest: *hotel, - }, nil - }, - } - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(mock) + h := NewHotelsHandler(newMock(nil)) app.Post("/hotels", h.CreateHotel) - missingNameBody := `{ + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "name": "The Grand Budapest Hotel", "floors": 10 - }` - - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(missingNameBody)) + }`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) @@ -198,33 +155,42 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { assert.Equal(t, 400, resp.StatusCode) body, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(body), "name") + assert.Contains(t, string(body), "id") }) - t.Run("returns 400 on empty name field", func(t *testing.T) { + t.Run("returns 400 on empty id", func(t *testing.T) { t.Parallel() - mock := &mockHotelsRepository{ - insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - return &models.Hotel{ - ID: "generated-uuid", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - CreateHotelRequest: *hotel, - }, nil - }, - } - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(mock) + h := NewHotelsHandler(newMock(nil)) app.Post("/hotels", h.CreateHotel) - emptyNameBody := `{ - "name": "", + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "id": "", + "name": "The Grand Budapest Hotel", "floors": 10 - }` + }`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 400, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "id") + }) - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(emptyNameBody)) + t.Run("returns 400 on missing name", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewHotelsHandler(newMock(nil)) + app.Post("/hotels", h.CreateHotel) + + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "id": "org_2abc123", + "floors": 10 + }`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) @@ -235,29 +201,18 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { assert.Contains(t, string(body), "name") }) - t.Run("returns 400 on missing required floors field", func(t *testing.T) { + t.Run("returns 400 on empty name", func(t *testing.T) { t.Parallel() - mock := &mockHotelsRepository{ - insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - return &models.Hotel{ - ID: "generated-uuid", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - CreateHotelRequest: *hotel, - }, nil - }, - } - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(mock) + h := NewHotelsHandler(newMock(nil)) app.Post("/hotels", h.CreateHotel) - missingFloorsBody := `{ - "name": "The Grand Budapest Hotel" - }` - - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(missingFloorsBody)) + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "id": "org_2abc123", + "name": "", + "floors": 10 + }`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) @@ -265,33 +220,21 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { assert.Equal(t, 400, resp.StatusCode) body, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(body), "floors") + assert.Contains(t, string(body), "name") }) - t.Run("returns 400 on floors equal to 0", func(t *testing.T) { + t.Run("returns 400 on negative floors", func(t *testing.T) { t.Parallel() - mock := &mockHotelsRepository{ - insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - return &models.Hotel{ - ID: "generated-uuid", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - CreateHotelRequest: *hotel, - }, nil - }, - } - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(mock) + h := NewHotelsHandler(newMock(nil)) app.Post("/hotels", h.CreateHotel) - zeroFloorsBody := `{ + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "id": "org_2abc123", "name": "The Grand Budapest Hotel", - "floors": 0 - }` - - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(zeroFloorsBody)) + "floors": -1 + }`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) @@ -302,30 +245,18 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { assert.Contains(t, string(body), "floors") }) - t.Run("returns 400 on negative floors", func(t *testing.T) { + t.Run("returns 400 on floors equal to 0", func(t *testing.T) { t.Parallel() - mock := &mockHotelsRepository{ - insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - return &models.Hotel{ - ID: "generated-uuid", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - CreateHotelRequest: *hotel, - }, nil - }, - } - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewHotelsHandler(mock) + h := NewHotelsHandler(newMock(nil)) app.Post("/hotels", h.CreateHotel) - negativeFloorsBody := `{ + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "id": "org_2abc123", "name": "The Grand Budapest Hotel", - "floors": -1 - }` - - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(negativeFloorsBody)) + "floors": 0 + }`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) @@ -336,6 +267,24 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { assert.Contains(t, string(body), "floors") }) + t.Run("returns 201 with no floors (optional)", func(t *testing.T) { + t.Parallel() + + app := fiber.New() + h := NewHotelsHandler(newMock(nil)) + app.Post("/hotels", h.CreateHotel) + + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "id": "org_2abc123", + "name": "The Grand Budapest Hotel" + }`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 201, resp.StatusCode) + }) + t.Run("returns 500 on db error", func(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/users_test.go b/backend/internal/handler/users_test.go index c924f614e..5257b738d 100644 --- a/backend/internal/handler/users_test.go +++ b/backend/internal/handler/users_test.go @@ -245,7 +245,8 @@ func TestUsersHandler_CreateUser(t *testing.T) { "id": "user_123", "first_name": "John", "last_name": "Doe", - "role": "Receptionist" + "role": "Receptionist", + "hotel_id": "550e8400-e29b-41d4-a716-446655440000" }` t.Run("returns 200 on valid user creation", func(t *testing.T) { @@ -303,7 +304,8 @@ func TestUsersHandler_CreateUser(t *testing.T) { "role": "Manager", "employee_id": "EMP-67", "department": "Front Desk", - "timezone": "America/New_York" + "timezone": "America/New_York", + "hotel_id": "550e8400-e29b-41d4-a716-446655440000" }` req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(bodyWithOptionals)) @@ -374,7 +376,8 @@ func TestUsersHandler_CreateUser(t *testing.T) { "first_name": "John", "last_name": "Doe", "role": "Receptionist", - "timezone": "Invalid/Not_A_Timezone" + "timezone": "Invalid/Not_A_Timezone", + "hotel_id": "550e8400-e29b-41d4-a716-446655440000" }` req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(invalidTimezoneBody)) diff --git a/backend/internal/handler/utils.go b/backend/internal/handler/utils.go index fcb121641..2196128cf 100644 --- a/backend/internal/handler/utils.go +++ b/backend/internal/handler/utils.go @@ -75,3 +75,16 @@ func ReformatUserData(CreateUserRequest *models.ClerkUser) *models.CreateUser { } return result } + +func ReformatOrgMembershipUserData(userData *models.OrgMembershipUserData, hotelID string) *models.CreateUser { + result := &models.CreateUser{ + ID: userData.UserID, + FirstName: userData.FirstName, + LastName: userData.LastName, + HotelID: hotelID, + } + if userData.HasImage { + result.ProfilePicture = userData.ImageUrl + } + return result +} diff --git a/backend/internal/models/clerk.go b/backend/internal/models/clerk.go new file mode 100644 index 000000000..babb1a199 --- /dev/null +++ b/backend/internal/models/clerk.go @@ -0,0 +1,31 @@ +package models + +// CreateUserOrgMembershipWebhook is the payload for Clerk's organizationMembership.created event. +// Clerk fires this when a user accepts an invitation to join an organization (hotel). +type CreateUserOrgMembershipWebhook struct { + Data OrgMembershipData `json:"data"` +} + +type OrgMembershipData struct { + Organization ClerkOrganization `json:"organization"` + PublicUserData OrgMembershipUserData `json:"public_user_data"` +} + +type ClerkOrganization struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type CreateOrgWebhook struct { + Data ClerkOrganization `json:"data"` +} + +// OrgMembershipUserData is the limited user snapshot Clerk includes in org membership events. +// Note: the user ID field is "user_id" here, unlike ClerkUser which uses "id". +type OrgMembershipUserData struct { + UserID string `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + ImageUrl *string `json:"image_url"` + HasImage bool `json:"has_image"` +} diff --git a/backend/internal/models/hotels.go b/backend/internal/models/hotels.go index b8d2c6c20..412cf04d7 100644 --- a/backend/internal/models/hotels.go +++ b/backend/internal/models/hotels.go @@ -3,12 +3,12 @@ package models import "time" type CreateHotelRequest struct { + ID string `json:"id" validate:"notblank" example:"org_2abc123"` Name string `json:"name" validate:"notblank" example:"Hotel California"` - Floors int `json:"floors" validate:"gte=1" example:"10"` + Floors *int `json:"floors,omitempty" validate:"omitempty,gte=1" example:"10"` } //@name CreateHotelRequest type Hotel struct { - ID string `json:"id" example:"550e8400-e29b-41d4-a 716-446655440000"` CreatedAt time.Time `json:"created_at" example:"2024-01-01T00:00:00Z"` UpdatedAt time.Time `json:"updated_at" example:"2024-01-01T00:00:00Z"` CreateHotelRequest diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go index 7f3308136..7ac4e6fbd 100644 --- a/backend/internal/models/users.go +++ b/backend/internal/models/users.go @@ -6,7 +6,7 @@ type CreateUser struct { ID string `json:"id" validate:"notblank" example:"user_123"` FirstName string `json:"first_name" validate:"notblank" example:"John"` LastName string `json:"last_name" validate:"notblank" example:"Doe"` - HotelID *string `json:"hotel_id,omitempty" validate:"omitempty" example:"hotel_123"` + HotelID string `json:"hotel_id" validate:"notblank" example:"550e8400-e29b-41d4-a716-446655440000"` EmployeeID *string `json:"employee_id,omitempty" validate:"omitempty" example:"EMP-1234"` ProfilePicture *string `json:"profile_picture,omitempty" validate:"omitempty,url" example:"https://example.com/john.jpg"` Role *string `json:"role,omitempty" validate:"omitempty" example:"Receptionist"` diff --git a/backend/internal/repository/hotels.go b/backend/internal/repository/hotels.go index a404998b4..0f75fb295 100644 --- a/backend/internal/repository/hotels.go +++ b/backend/internal/repository/hotels.go @@ -21,7 +21,7 @@ func NewHotelsRepository(db *pgxpool.Pool) *HotelsRepository { func (r *HotelsRepository) FindByID(ctx context.Context, id string) (*models.Hotel, error) { row := r.db.QueryRow(ctx, ` SELECT id, name, floors, created_at, updated_at - FROM hotels + FROM hotels WHERE id = $1 `, id) @@ -39,22 +39,19 @@ func (r *HotelsRepository) FindByID(ctx context.Context, id string) (*models.Hot func (r *HotelsRepository) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { createdHotel := &models.Hotel{CreateHotelRequest: *hotel} - err := r.db.QueryRow(ctx, ` - INSERT INTO hotels ( - name, floors - ) VALUES ( - $1, $2 - ) - RETURNING id, created_at, updated_at - `, - hotel.Name, - hotel.Floors, - ).Scan(&createdHotel.ID, &createdHotel.CreatedAt, &createdHotel.UpdatedAt) - + INSERT INTO hotels (id, name, floors) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING + RETURNING created_at, updated_at + `, hotel.ID, hotel.Name, hotel.Floors).Scan( + &createdHotel.CreatedAt, &createdHotel.UpdatedAt, + ) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.ErrAlreadyExistsInDB + } return nil, err } - return createdHotel, nil } diff --git a/backend/internal/service/clerk/organizations.go b/backend/internal/service/clerk/organizations.go new file mode 100644 index 000000000..0eb58027b --- /dev/null +++ b/backend/internal/service/clerk/organizations.go @@ -0,0 +1,20 @@ +package clerk + +import ( + "context" + + clerksdk "github.com/clerk/clerk-sdk-go/v2" + "github.com/clerk/clerk-sdk-go/v2/organization" +) + +func CreateClerkOrg(ctx context.Context, name string, createdByUserID string) (string, error) { + org, err := organization.Create(ctx, &organization.CreateParams{ + Name: clerksdk.String(name), + CreatedBy: clerksdk.String(createdByUserID), + }) + if err != nil { + return "", err + } + + return org.ID, nil +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index bd64daf00..709735b64 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -126,8 +126,9 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo return c.SendStatus(http.StatusOK) }) - // initialize users repo + // initialize users and hotels repos for clerk webhook handler usersRepo := repository.NewUsersRepository(repo.DB) + hotelsRepo := repository.NewHotelsRepository(repo.DB) // initialize notifications notifRepo := repository.NewNotificationsRepository(repo.DB) @@ -149,14 +150,15 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo if err != nil { return err } - clerkWebhookHandler := handler.NewClerkWebHookHandler(usersRepo, clerkWhSignatureVerifier) + clerkWebhookHandler := handler.NewClerkWebHookHandler(usersRepo, hotelsRepo, clerkWhSignatureVerifier) // API v1 routes api := app.Group("/api/v1") - // clerk webhook route + // clerk webhook routes api.Route("/clerk", func(r fiber.Router) { - r.Post("/user", clerkWebhookHandler.CreateUser) + r.Post("/org-membership", clerkWebhookHandler.CreateOrgMembership) + r.Post("/org", clerkWebhookHandler.OrgCreated) }) verifier := clerk.NewClerkJWTVerifier() diff --git a/backend/internal/tests/clerk_test.go b/backend/internal/tests/clerk_test.go index 2e9bbbb28..7c3fabda4 100644 --- a/backend/internal/tests/clerk_test.go +++ b/backend/internal/tests/clerk_test.go @@ -33,90 +33,228 @@ type mockUsersRepositoryClerk struct { func (m *mockUsersRepositoryClerk) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) { return m.insertUserFunc(ctx, user) } - func (m *mockUsersRepositoryClerk) BulkInsertUsers(ctx context.Context, users []*models.CreateUser) error { return nil } - func (m *mockUsersRepositoryClerk) FindUser(ctx context.Context, id string) (*models.User, error) { return nil, nil } - func (m *mockUsersRepositoryClerk) UpdateProfilePicture(ctx context.Context, userId string, key string) error { return nil } - func (m *mockUsersRepositoryClerk) DeleteProfilePicture(ctx context.Context, userId string) error { return nil } - func (m *mockUsersRepositoryClerk) GetKey(ctx context.Context, userId string) (string, error) { return "", nil } - func (m *mockUsersRepositoryClerk) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { return nil, nil } - func (m *mockUsersRepositoryClerk) SearchUsersByHotel(ctx context.Context, hotelID, cursor, query string, limit int) ([]*models.User, string, error) { return nil, "", nil } -// Makes the compiler verify the mock implements the interface var _ storage.UsersRepository = (*mockUsersRepositoryClerk)(nil) -func TestClerkHandler_CreateUser(t *testing.T) { +type mockHotelsRepositoryClerk struct { + findByIDFunc func(ctx context.Context, id string) (*models.Hotel, error) + insertHotelFunc func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) +} + +func (m *mockHotelsRepositoryClerk) FindByID(ctx context.Context, id string) (*models.Hotel, error) { + if m.findByIDFunc != nil { + return m.findByIDFunc(ctx, id) + } + return nil, nil +} +func (m *mockHotelsRepositoryClerk) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + if m.insertHotelFunc != nil { + return m.insertHotelFunc(ctx, hotel) + } + return nil, nil +} + +var _ storage.HotelsRepository = (*mockHotelsRepositoryClerk)(nil) + +func validVerifier() *mockWebhookVerifier { + return &mockWebhookVerifier{ + verifyFunc: func(payload []byte, headers http.Header) error { return nil }, + } +} + +func invalidVerifier() *mockWebhookVerifier { + return &mockWebhookVerifier{ + verifyFunc: func(payload []byte, headers http.Header) error { + return errors.New("invalid signature") + }, + } +} + +func svixHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("svix-id", "msg_123") + req.Header.Set("svix-timestamp", "1614265330") + req.Header.Set("svix-signature", "v1,valid") +} + +func TestClerkHandler_OrgCreated(t *testing.T) { t.Parallel() validPayload := `{ "data": { - "id": "user_123", - "first_name": "John", - "last_name": "Doe", - "has_image": false, - "image_url": "" + "id": "org_123", + "name": "Hotel California" } }` t.Run("returns 401 when signature verification fails", func(t *testing.T) { t.Parallel() - webhookMock := &mockWebhookVerifier{ - verifyFunc: func(payload []byte, headers http.Header) error { - return errors.New("invalid signature") + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, &mockHotelsRepositoryClerk{}, invalidVerifier()) + app.Post("/webhook", h.OrgCreated) + + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) + svixHeaders(req) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) + }) + + t.Run("returns 200 and creates hotel when org is created", func(t *testing.T) { + t.Parallel() + + var capturedReq *models.CreateHotelRequest + hotelMock := &mockHotelsRepositoryClerk{ + insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + capturedReq = hotel + return &models.Hotel{CreateHotelRequest: *hotel}, nil }, } - userMock := &mockUsersRepositoryClerk{ - insertUserFunc: func(ctx context.Context, user *models.CreateUser) (*models.User, error) { - return nil, nil + app := fiber.New() + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, hotelMock, validVerifier()) + app.Post("/webhook", h.OrgCreated) + + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) + svixHeaders(req) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "org_123", capturedReq.ID) + assert.Equal(t, "Hotel California", capturedReq.Name) + }) + + t.Run("returns 200 when hotel already exists (idempotent)", func(t *testing.T) { + t.Parallel() + + hotelMock := &mockHotelsRepositoryClerk{ + insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + return nil, errs.ErrAlreadyExistsInDB }, } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := handler.NewClerkWebHookHandler(userMock, webhookMock) - app.Post("/webhook", h.CreateUser) + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, hotelMock, validVerifier()) + app.Post("/webhook", h.OrgCreated) req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("svix-id", "msg_123") - req.Header.Set("svix-timestamp", "1614265330") - req.Header.Set("svix-signature", "invalid") + svixHeaders(req) resp, err := app.Test(req) require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) + t.Run("returns 400 when payload is invalid JSON", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, &mockHotelsRepositoryClerk{}, validVerifier()) + app.Post("/webhook", h.OrgCreated) + + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(`{invalid`)) + svixHeaders(req) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 500 on db error", func(t *testing.T) { + t.Parallel() + + hotelMock := &mockHotelsRepositoryClerk{ + insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + return nil, errors.New("db error") + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, hotelMock, validVerifier()) + app.Post("/webhook", h.OrgCreated) + + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) + svixHeaders(req) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 500, resp.StatusCode) + }) +} + +func TestClerkHandler_CreateOrgMembership(t *testing.T) { + t.Parallel() + + validPayload := `{ + "data": { + "organization": { + "id": "org_123", + "name": "Hotel California" + }, + "public_user_data": { + "user_id": "user_123", + "first_name": "John", + "last_name": "Doe", + "has_image": false, + "image_url": null + } + } + }` + + hotelID := "org_123" + + t.Run("returns 401 when signature verification fails", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, &mockHotelsRepositoryClerk{}, invalidVerifier()) + app.Post("/webhook", h.CreateOrgMembership) + + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) + svixHeaders(req) + + resp, err := app.Test(req) + require.NoError(t, err) assert.Equal(t, 401, resp.StatusCode) }) - t.Run("returns 200 and creates user when signature is valid", func(t *testing.T) { + t.Run("returns 200 and creates user when membership is created", func(t *testing.T) { t.Parallel() var capturedUser *models.CreateUser - webhookMock := &mockWebhookVerifier{ - verifyFunc: func(payload []byte, headers http.Header) error { - return nil + hotelMock := &mockHotelsRepositoryClerk{ + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{ + CreateHotelRequest: models.CreateHotelRequest{ + ID: hotelID, + Name: "Hotel California", + }, + }, nil }, } @@ -132,101 +270,63 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New() - h := handler.NewClerkWebHookHandler(userMock, webhookMock) - app.Post("/webhook", h.CreateUser) + h := handler.NewClerkWebHookHandler(userMock, hotelMock, validVerifier()) + app.Post("/webhook", h.CreateOrgMembership) req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("svix-id", "msg_123") - req.Header.Set("svix-timestamp", "1614265330") - req.Header.Set("svix-signature", "v1,valid") + svixHeaders(req) resp, err := app.Test(req) require.NoError(t, err) - assert.Equal(t, 200, resp.StatusCode) assert.Equal(t, "user_123", capturedUser.ID) assert.Equal(t, "John", capturedUser.FirstName) assert.Equal(t, "Doe", capturedUser.LastName) + assert.Equal(t, hotelID, capturedUser.HotelID) }) - t.Run("returns 400 when payload JSON is invalid", func(t *testing.T) { + t.Run("returns 404 when hotel not found", func(t *testing.T) { t.Parallel() - webhookMock := &mockWebhookVerifier{ - verifyFunc: func(payload []byte, headers http.Header) error { - return nil - }, - } - - userMock := &mockUsersRepositoryClerk{ - insertUserFunc: func(ctx context.Context, user *models.CreateUser) (*models.User, error) { - return nil, nil + hotelMock := &mockHotelsRepositoryClerk{ + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + return nil, errs.ErrNotFoundInDB }, } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := handler.NewClerkWebHookHandler(userMock, webhookMock) - app.Post("/webhook", h.CreateUser) + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, hotelMock, validVerifier()) + app.Post("/webhook", h.CreateOrgMembership) - req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(`{invalid`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("svix-id", "msg_123") - req.Header.Set("svix-timestamp", "1614265330") - req.Header.Set("svix-signature", "v1,valid") + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) + svixHeaders(req) resp, err := app.Test(req) require.NoError(t, err) - - assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, 404, resp.StatusCode) }) - t.Run("returns 400 when required fields are missing", func(t *testing.T) { + t.Run("returns 400 when payload is invalid JSON", func(t *testing.T) { t.Parallel() - webhookMock := &mockWebhookVerifier{ - verifyFunc: func(payload []byte, headers http.Header) error { - return nil - }, - } - - userMock := &mockUsersRepositoryClerk{ - insertUserFunc: func(ctx context.Context, user *models.CreateUser) (*models.User, error) { - return nil, nil - }, - } - app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := handler.NewClerkWebHookHandler(userMock, webhookMock) - app.Post("/webhook", h.CreateUser) - - invalidPayload := `{ - "type": "user.created", - "data": { - "id": "", - "first_name": "", - "last_name": "" - } - }` + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, &mockHotelsRepositoryClerk{}, validVerifier()) + app.Post("/webhook", h.CreateOrgMembership) - req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(invalidPayload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("svix-id", "msg_123") - req.Header.Set("svix-timestamp", "1614265330") - req.Header.Set("svix-signature", "v1,valid") + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(`{invalid`)) + svixHeaders(req) resp, err := app.Test(req) require.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) }) - t.Run("returns 500 when user creation fails", func(t *testing.T) { + t.Run("returns 500 when user insertion fails", func(t *testing.T) { t.Parallel() - webhookMock := &mockWebhookVerifier{ - verifyFunc: func(payload []byte, headers http.Header) error { - return nil + hotelMock := &mockHotelsRepositoryClerk{ + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: models.CreateHotelRequest{ID: hotelID}}, nil }, } @@ -237,107 +337,102 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := handler.NewClerkWebHookHandler(userMock, webhookMock) - app.Post("/webhook", h.CreateUser) + h := handler.NewClerkWebHookHandler(userMock, hotelMock, validVerifier()) + app.Post("/webhook", h.CreateOrgMembership) req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("svix-id", "msg_123") - req.Header.Set("svix-timestamp", "1614265330") - req.Header.Set("svix-signature", "v1,valid") + svixHeaders(req) resp, err := app.Test(req) require.NoError(t, err) - assert.Equal(t, 500, resp.StatusCode) }) - t.Run("passes correct headers to verifier", func(t *testing.T) { + t.Run("sets profile picture when has_image is true", func(t *testing.T) { t.Parallel() - var capturedHeaders http.Header + var capturedUser *models.CreateUser - webhookMock := &mockWebhookVerifier{ - verifyFunc: func(payload []byte, headers http.Header) error { - capturedHeaders = headers - return nil + hotelMock := &mockHotelsRepositoryClerk{ + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: models.CreateHotelRequest{ID: hotelID}}, nil }, } userMock := &mockUsersRepositoryClerk{ insertUserFunc: func(ctx context.Context, user *models.CreateUser) (*models.User, error) { - return &models.User{ - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - CreateUser: *user, - }, nil + capturedUser = user + return &models.User{CreatedAt: time.Now(), UpdatedAt: time.Now(), CreateUser: *user}, nil }, } app := fiber.New() - h := handler.NewClerkWebHookHandler(userMock, webhookMock) - app.Post("/webhook", h.CreateUser) + h := handler.NewClerkWebHookHandler(userMock, hotelMock, validVerifier()) + app.Post("/webhook", h.CreateOrgMembership) - req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("svix-id", "msg_abc") - req.Header.Set("svix-timestamp", "1234567890") - req.Header.Set("svix-signature", "v1,signature123") + payloadWithImage := `{ + "data": { + "organization": { + "id": "org_123", + "name": "Hotel California" + }, + "public_user_data": { + "user_id": "user_123", + "first_name": "John", + "last_name": "Doe", + "has_image": true, + "image_url": "https://example.com/photo.jpg" + } + } + }` + + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(payloadWithImage)) + svixHeaders(req) _, err := app.Test(req) require.NoError(t, err) - - assert.Equal(t, "msg_abc", capturedHeaders.Get("svix-id")) - assert.Equal(t, "1234567890", capturedHeaders.Get("svix-timestamp")) - assert.Equal(t, "v1,signature123", capturedHeaders.Get("svix-signature")) + assert.Equal(t, "https://example.com/photo.jpg", *capturedUser.ProfilePicture) }) - t.Run("sets profile picture when has_image is true", func(t *testing.T) { + t.Run("passes correct headers to verifier", func(t *testing.T) { t.Parallel() - var capturedUser *models.CreateUser + var capturedHeaders http.Header - webhookMock := &mockWebhookVerifier{ + verifierMock := &mockWebhookVerifier{ verifyFunc: func(payload []byte, headers http.Header) error { + capturedHeaders = headers return nil }, } + hotelMock := &mockHotelsRepositoryClerk{ + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: models.CreateHotelRequest{ID: hotelID}}, nil + }, + } + userMock := &mockUsersRepositoryClerk{ insertUserFunc: func(ctx context.Context, user *models.CreateUser) (*models.User, error) { - capturedUser = user - return &models.User{ - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - CreateUser: *user, - }, nil + return &models.User{CreatedAt: time.Now(), UpdatedAt: time.Now(), CreateUser: *user}, nil }, } app := fiber.New() - h := handler.NewClerkWebHookHandler(userMock, webhookMock) - app.Post("/webhook", h.CreateUser) - - payloadWithImage := `{ - "type": "user.created", - "data": { - "id": "user_123", - "first_name": "John", - "last_name": "Doe", - "has_image": true, - "image_url": "https://example.com/photo.jpg" - } - }` + h := handler.NewClerkWebHookHandler(userMock, hotelMock, verifierMock) + app.Post("/webhook", h.CreateOrgMembership) - req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(payloadWithImage)) + req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("svix-id", "msg_123") - req.Header.Set("svix-timestamp", "1614265330") - req.Header.Set("svix-signature", "v1,valid") + req.Header.Set("svix-id", "msg_abc") + req.Header.Set("svix-timestamp", "1234567890") + req.Header.Set("svix-signature", "v1,signature123") _, err := app.Test(req) require.NoError(t, err) - assert.Equal(t, "https://example.com/photo.jpg", *capturedUser.ProfilePicture) + assert.Equal(t, "msg_abc", capturedHeaders.Get("svix-id")) + assert.Equal(t, "1234567890", capturedHeaders.Get("svix-timestamp")) + assert.Equal(t, "v1,signature123", capturedHeaders.Get("svix-signature")) }) } diff --git a/backend/internal/utils/clerk.go b/backend/internal/utils/clerk.go new file mode 100644 index 000000000..52261d922 --- /dev/null +++ b/backend/internal/utils/clerk.go @@ -0,0 +1,23 @@ +package utils + +import ( + "context" + "encoding/json" + + "github.com/clerk/clerk-sdk-go/v2/organization" +) + +func UpdateOrgMetadata(ctx context.Context, clerkOrgID string, hotelID string) error { + metadata, err := json.Marshal(map[string]string{ + "hotel_id": hotelID, + }) + if err != nil { + return err + } + + raw := json.RawMessage(metadata) + _, err = organization.Update(ctx, clerkOrgID, &organization.UpdateParams{ + PublicMetadata: &raw, + }) + return err +}