From cbddc010f7872c4b08ea2d2620c180793c62fc5d Mon Sep 17 00:00:00 2001 From: eric-kitagawa Date: Sun, 15 Mar 2026 00:09:11 -0400 Subject: [PATCH 1/5] first pass on clerk orgs --- backend/internal/handler/clerk.go | 49 +++++++++++++------ backend/internal/handler/utils.go | 13 +++++ backend/internal/models/hotels.go | 7 +-- backend/internal/models/users.go | 30 ++++++++++-- backend/internal/repository/hotels.go | 30 ++++++++++-- backend/internal/repository/users.go | 9 ++-- backend/internal/service/server.go | 9 ++-- .../service/storage/postgres/repo_types.go | 1 + ...20260315000000_hotels-add-clerk-org-id.sql | 1 + 9 files changed, 114 insertions(+), 35 deletions(-) create mode 100644 backend/supabase/migrations/20260315000000_hotels-add-clerk-org-id.sql diff --git a/backend/internal/handler/clerk.go b/backend/internal/handler/clerk.go index 55991ae8f..ee735c41e 100644 --- a/backend/internal/handler/clerk.go +++ b/backend/internal/handler/clerk.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "github.com/generate/selfserve/config" @@ -12,8 +13,9 @@ import ( ) type ClerkWebHookHandler struct { - UsersRepository storage.UsersRepository - WebhookVerifier WebhookVerifier + UsersRepository storage.UsersRepository + HotelsRepository storage.HotelsRepository + WebhookVerifier WebhookVerifier } type WebhookVerifier interface { @@ -24,34 +26,51 @@ 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 +} - var CreateUserRequest models.CreateUserWebhook - if err := c.BodyParser(&CreateUserRequest); err != nil { +// OrgMembershipCreated handles Clerk's organizationMembership.created webhook. +// This is the canonical user creation point for hotel staff — the org ID in the +// payload maps to a hotel, so we can create the user with hotel_id in one step. +func (h *ClerkWebHookHandler) CreateOrgMembership(c *fiber.Ctx) error { + if err := h.verifySvix(c); err != nil { + return err + } + + var payload models.CreateUserOrgMembershipWebhook + if err := c.BodyParser(&payload); err != nil { return errs.InvalidJSON() } - clerkUser := &CreateUserRequest.ClerkUser - if err := ValidateCreateUserClerk(clerkUser); err != nil { - return err + + hotel, err := h.HotelsRepository.FindByClerkOrgID(c.Context(), payload.Data.Organization.ID) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.BadRequest("organization is not associated with a hotel") + } + return errs.InternalServerError() } - res, err := h.UsersRepository.InsertUser(c.Context(), ReformatUserData(clerkUser)) + userData := &payload.Data.PublicUserData + user, err := h.UsersRepository.InsertUser(c.Context(), ReformatOrgMembershipUserData(userData, hotel.ID)) if err != nil { return errs.InternalServerError() } - return c.JSON(res) + return c.JSON(user) } diff --git a/backend/internal/handler/utils.go b/backend/internal/handler/utils.go index 40cc6649f..dfb97d358 100644 --- a/backend/internal/handler/utils.go +++ b/backend/internal/handler/utils.go @@ -61,3 +61,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/hotels.go b/backend/internal/models/hotels.go index b8d2c6c20..1f25588e6 100644 --- a/backend/internal/models/hotels.go +++ b/backend/internal/models/hotels.go @@ -3,12 +3,13 @@ package models import "time" type CreateHotelRequest struct { - Name string `json:"name" validate:"notblank" example:"Hotel California"` - Floors int `json:"floors" validate:"gte=1" example:"10"` + Name string `json:"name" validate:"notblank" example:"Hotel California"` + Floors int `json:"floors" validate:"gte=1" example:"10"` + ClerkOrgID string `json:"clerk_org_id" validate:"notblank" example:"org_2abc123"` } //@name CreateHotelRequest type Hotel struct { - ID string `json:"id" example:"550e8400-e29b-41d4-a 716-446655440000"` + ID string `json:"id" example:"550e8400-e29b-41d4-a716-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 728cb2e4e..bdd6aa3d0 100644 --- a/backend/internal/models/users.go +++ b/backend/internal/models/users.go @@ -6,6 +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" 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"` @@ -13,10 +14,6 @@ type CreateUser struct { Timezone *string `json:"timezone,omitempty" validate:"omitempty,timezone" example:"America/New_York"` } //@name CreateUser -type CreateUserWebhook struct { - ClerkUser `json:"data"` -} - type ClerkUser struct { ID string `json:"id" example:"user123402"` FirstName string `json:"first_name" example:"John"` @@ -25,6 +22,31 @@ type ClerkUser struct { HasImage bool `json:"has_image" example:"true"` } +// 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"` +} + +// 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"` +} + type User struct { CreateUser CreatedAt time.Time `json:"created_at" example:"2024-01-01T00:00:00Z"` diff --git a/backend/internal/repository/hotels.go b/backend/internal/repository/hotels.go index a404998b4..060b3c5a1 100644 --- a/backend/internal/repository/hotels.go +++ b/backend/internal/repository/hotels.go @@ -20,13 +20,13 @@ 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 + SELECT id, name, floors, clerk_org_id, created_at, updated_at + FROM hotels WHERE id = $1 `, id) var hotel models.Hotel - err := row.Scan(&hotel.ID, &hotel.Name, &hotel.Floors, &hotel.CreatedAt, &hotel.UpdatedAt) + err := row.Scan(&hotel.ID, &hotel.Name, &hotel.Floors, &hotel.ClerkOrgID, &hotel.CreatedAt, &hotel.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.ErrNotFoundInDB @@ -42,14 +42,15 @@ func (r *HotelsRepository) InsertHotel(ctx context.Context, hotel *models.Create err := r.db.QueryRow(ctx, ` INSERT INTO hotels ( - name, floors + name, floors, clerk_org_id ) VALUES ( - $1, $2 + $1, $2, $3 ) RETURNING id, created_at, updated_at `, hotel.Name, hotel.Floors, + hotel.ClerkOrgID, ).Scan(&createdHotel.ID, &createdHotel.CreatedAt, &createdHotel.UpdatedAt) if err != nil { @@ -58,3 +59,22 @@ func (r *HotelsRepository) InsertHotel(ctx context.Context, hotel *models.Create return createdHotel, nil } + +func (r *HotelsRepository) FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + row := r.db.QueryRow(ctx, ` + SELECT id, name, floors, clerk_org_id, created_at, updated_at + FROM hotels + WHERE clerk_org_id = $1 + `, clerkOrgID) + + var hotel models.Hotel + err := row.Scan(&hotel.ID, &hotel.Name, &hotel.Floors, &hotel.ClerkOrgID, &hotel.CreatedAt, &hotel.UpdatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.ErrNotFoundInDB + } + return nil, err + } + + return &hotel, nil +} diff --git a/backend/internal/repository/users.go b/backend/internal/repository/users.go index a0dc53574..e73fb7d93 100644 --- a/backend/internal/repository/users.go +++ b/backend/internal/repository/users.go @@ -20,11 +20,11 @@ func NewUsersRepository(db *pgxpool.Pool) *UsersRepository { func (r *UsersRepository) FindUser(ctx context.Context, id string) (*models.User, error) { row := r.db.QueryRow(ctx, ` - SELECT id, first_name, last_name, employee_id, profile_picture, role, department, timezone, created_at, updated_at FROM users where id = $1 + SELECT id, first_name, last_name, hotel_id, employee_id, profile_picture, role, department, timezone, created_at, updated_at FROM users WHERE id = $1 `, id) var user models.User - err := row.Scan(&user.ID, &user.FirstName, &user.LastName, &user.EmployeeID, &user.ProfilePicture, &user.Role, &user.Department, &user.Timezone, &user.CreatedAt, &user.UpdatedAt) + err := row.Scan(&user.ID, &user.FirstName, &user.LastName, &user.HotelID, &user.EmployeeID, &user.ProfilePicture, &user.Role, &user.Department, &user.Timezone, &user.CreatedAt, &user.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.ErrNotFoundInDB @@ -41,15 +41,16 @@ func (r *UsersRepository) InsertUser(ctx context.Context, user *models.CreateUse err := r.db.QueryRow(ctx, ` INSERT INTO public.users ( - id, first_name, last_name, employee_id, profile_picture, role, department, timezone + id, first_name, last_name, hotel_id, employee_id, profile_picture, role, department, timezone ) VALUES ( - $1, $2, $3, $4, $5, $6, COALESCE($7, 'UTC'), $8 + $1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, 'UTC') ) RETURNING id, created_at, updated_at `, user.ID, user.FirstName, user.LastName, + user.HotelID, user.EmployeeID, user.ProfilePicture, user.Role, diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 2ae9cf353..359baa214 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -75,8 +75,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 handler(s) helloHandler := handler.NewHelloHandler() @@ -90,14 +91,14 @@ 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) }) verifier := clerk.NewClerkJWTVerifier() diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 4201f0e80..b3e18ee21 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -28,4 +28,5 @@ type RequestsRepository interface { type HotelsRepository interface { FindByID(ctx context.Context, id string) (*models.Hotel, error) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) + FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) } diff --git a/backend/supabase/migrations/20260315000000_hotels-add-clerk-org-id.sql b/backend/supabase/migrations/20260315000000_hotels-add-clerk-org-id.sql new file mode 100644 index 000000000..07f077fb4 --- /dev/null +++ b/backend/supabase/migrations/20260315000000_hotels-add-clerk-org-id.sql @@ -0,0 +1 @@ +ALTER TABLE public.hotels ADD COLUMN clerk_org_id text UNIQUE; From 4d48289bda089121ad54f89f002a849448f45d67 Mon Sep 17 00:00:00 2001 From: Manuel Torres Date: Sun, 5 Apr 2026 12:35:48 -0400 Subject: [PATCH 2/5] update of clerk orgs --- backend/internal/handler/clerk.go | 32 +- backend/internal/handler/hotels_test.go | 42 +- backend/internal/handler/users_test.go | 11 +- backend/internal/models/users.go | 5 + backend/internal/repository/hotels.go | 20 + .../internal/service/clerk/organizations.go | 30 ++ backend/internal/service/server.go | 1 + .../service/storage/postgres/repo_types.go | 1 + backend/internal/tests/clerk_test.go | 396 +++++++++++------- 9 files changed, 367 insertions(+), 171 deletions(-) create mode 100644 backend/internal/service/clerk/organizations.go diff --git a/backend/internal/handler/clerk.go b/backend/internal/handler/clerk.go index ee735c41e..2c2900a99 100644 --- a/backend/internal/handler/clerk.go +++ b/backend/internal/handler/clerk.go @@ -45,9 +45,6 @@ func (h *ClerkWebHookHandler) verifySvix(c *fiber.Ctx) error { return nil } -// OrgMembershipCreated handles Clerk's organizationMembership.created webhook. -// This is the canonical user creation point for hotel staff — the org ID in the -// payload maps to a hotel, so we can create the user with hotel_id in one step. func (h *ClerkWebHookHandler) CreateOrgMembership(c *fiber.Ctx) error { if err := h.verifySvix(c); err != nil { return err @@ -61,16 +58,39 @@ func (h *ClerkWebHookHandler) CreateOrgMembership(c *fiber.Ctx) error { hotel, err := h.HotelsRepository.FindByClerkOrgID(c.Context(), payload.Data.Organization.ID) if err != nil { if errors.Is(err, errs.ErrNotFoundInDB) { - return errs.BadRequest("organization is not associated with a hotel") + return c.SendStatus(fiber.StatusServiceUnavailable) } return errs.InternalServerError() } userData := &payload.Data.PublicUserData - user, err := h.UsersRepository.InsertUser(c.Context(), ReformatOrgMembershipUserData(userData, hotel.ID)) + _, err = h.UsersRepository.InsertUser(c.Context(), ReformatOrgMembershipUserData(userData, hotel.ID)) if err != nil { return errs.InternalServerError() } - return c.JSON(user) + 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 + } + + var payload models.CreateOrgWebhook + if err := c.BodyParser(&payload); err != nil { + return errs.InvalidJSON() + } + + _, err := h.HotelsRepository.InsertHotelFromClerkOrg(c.Context(), payload.Data.ID, payload.Data.Name) + if err != nil { + if errors.Is(err, errs.ErrAlreadyExistsInDB) { + return c.SendStatus(fiber.StatusOK) + } + return errs.InternalServerError() + } + + return c.SendStatus(fiber.StatusOK) } diff --git a/backend/internal/handler/hotels_test.go b/backend/internal/handler/hotels_test.go index ddd9dd7d5..0a55e8e81 100644 --- a/backend/internal/handler/hotels_test.go +++ b/backend/internal/handler/hotels_test.go @@ -30,6 +30,14 @@ func (m *mockHotelsRepository) InsertHotel(ctx context.Context, hotel *models.Cr return m.insertHotelFunc(ctx, hotel) } +func (m *mockHotelsRepository) FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + return nil, nil +} + +func (m *mockHotelsRepository) InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { + return nil, nil +} + func TestHotelHandler_GetHotelByID(t *testing.T) { t.Parallel() @@ -107,7 +115,8 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() validBody := `{ "name": "The Grand Budapest Hotel", - "floors": 10 + "floors": 10, + "clerk_org_id": "org_123" }` t.Run("returns 201 on success", func(t *testing.T) { @@ -187,8 +196,9 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { app.Post("/hotels", h.CreateHotel) missingNameBody := `{ - "floors": 10 - }` + "floors": 10, + "clerk_org_id": "org_123" +}` req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(missingNameBody)) req.Header.Set("Content-Type", "application/json") @@ -220,9 +230,10 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { app.Post("/hotels", h.CreateHotel) emptyNameBody := `{ - "name": "", - "floors": 10 - }` + "name": "", + "floors": 10, + "clerk_org_id": "org_123" +}` req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(emptyNameBody)) req.Header.Set("Content-Type", "application/json") @@ -254,8 +265,9 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { app.Post("/hotels", h.CreateHotel) missingFloorsBody := `{ - "name": "The Grand Budapest Hotel" - }` + "name": "The Grand Budapest Hotel", + "clerk_org_id": "org_123" +}` req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(missingFloorsBody)) req.Header.Set("Content-Type", "application/json") @@ -287,9 +299,10 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { app.Post("/hotels", h.CreateHotel) zeroFloorsBody := `{ - "name": "The Grand Budapest Hotel", - "floors": 0 - }` + "name": "The Grand Budapest Hotel", + "floors": 0, + "clerk_org_id": "org_123" +}` req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(zeroFloorsBody)) req.Header.Set("Content-Type", "application/json") @@ -321,9 +334,10 @@ func TestHotelsHandler_CreateHotel(t *testing.T) { app.Post("/hotels", h.CreateHotel) negativeFloorsBody := `{ - "name": "The Grand Budapest Hotel", - "floors": -1 - }` + "name": "The Grand Budapest Hotel", + "floors": -1, + "clerk_org_id": "org_123" +}` req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(negativeFloorsBody)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/handler/users_test.go b/backend/internal/handler/users_test.go index c924f614e..e6d74028a 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,9 +376,10 @@ 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)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go index b0b2610bf..78ef600da 100644 --- a/backend/internal/models/users.go +++ b/backend/internal/models/users.go @@ -46,6 +46,11 @@ type OrgMembershipData struct { 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. diff --git a/backend/internal/repository/hotels.go b/backend/internal/repository/hotels.go index 060b3c5a1..10f6dd744 100644 --- a/backend/internal/repository/hotels.go +++ b/backend/internal/repository/hotels.go @@ -78,3 +78,23 @@ func (r *HotelsRepository) FindByClerkOrgID(ctx context.Context, clerkOrgID stri return &hotel, nil } + +func (r *HotelsRepository) InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { + var hotel models.Hotel + err := r.db.QueryRow(ctx, ` + INSERT INTO hotels (name, clerk_org_id) + VALUES ($1, $2) + ON CONFLICT (clerk_org_id) DO NOTHING + RETURNING id, name, floors, clerk_org_id, created_at, updated_at + `, name, clerkOrgID).Scan( + &hotel.ID, &hotel.Name, &hotel.Floors, + &hotel.ClerkOrgID, &hotel.CreatedAt, &hotel.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.ErrAlreadyExistsInDB + } + return nil, err + } + return &hotel, nil +} \ No newline at end of file diff --git a/backend/internal/service/clerk/organizations.go b/backend/internal/service/clerk/organizations.go new file mode 100644 index 000000000..335d80e4b --- /dev/null +++ b/backend/internal/service/clerk/organizations.go @@ -0,0 +1,30 @@ +package clerk + +import ( + "context" + "encoding/json" + + 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, hotelID string) (string, error) { + metadata, err := json.Marshal(map[string]string{ + "hotel_id": hotelID, + }) + if err != nil { + return "", err + } + + raw := json.RawMessage(metadata) + org, err := organization.Create(ctx, &organization.CreateParams{ + Name: clerksdk.String(name), + CreatedBy: clerksdk.String(createdByUserID), + PublicMetadata: &raw, + }) + if err != nil { + return "", err + } + + return org.ID, nil +} \ No newline at end of file diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 5cc363cba..709735b64 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -158,6 +158,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo // clerk webhook routes api.Route("/clerk", func(r fiber.Router) { r.Post("/org-membership", clerkWebhookHandler.CreateOrgMembership) + r.Post("/org", clerkWebhookHandler.OrgCreated) }) verifier := clerk.NewClerkJWTVerifier() diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index be79fbfee..53e2a40b3 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -57,6 +57,7 @@ type HotelsRepository interface { FindByID(ctx context.Context, id string) (*models.Hotel, error) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) + InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) } // S3Storage defines the interface for S3 operations diff --git a/backend/internal/tests/clerk_test.go b/backend/internal/tests/clerk_test.go index 2e9bbbb28..07b36687a 100644 --- a/backend/internal/tests/clerk_test.go +++ b/backend/internal/tests/clerk_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/require" ) + type mockWebhookVerifier struct { verifyFunc func(payload []byte, headers http.Header) error } @@ -33,90 +34,234 @@ 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 { + findByClerkOrgIDFunc func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) + insertHotelFromClerkOrgFunc func(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) +} + +func (m *mockHotelsRepositoryClerk) FindByID(ctx context.Context, id string) (*models.Hotel, error) { + return nil, nil +} +func (m *mockHotelsRepositoryClerk) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + return nil, nil +} +func (m *mockHotelsRepositoryClerk) FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + if m.findByClerkOrgIDFunc != nil { + return m.findByClerkOrgIDFunc(ctx, clerkOrgID) + } + return nil, nil +} +func (m *mockHotelsRepositoryClerk) InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { + if m.insertHotelFromClerkOrgFunc != nil { + return m.insertHotelFromClerkOrgFunc(ctx, clerkOrgID, name) + } + 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 capturedOrgID, capturedName string + hotelMock := &mockHotelsRepositoryClerk{ + insertHotelFromClerkOrgFunc: func(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { + capturedOrgID = clerkOrgID + capturedName = name + return &models.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", capturedOrgID) + assert.Equal(t, "Hotel California", capturedName) + }) + + t.Run("returns 200 when hotel already exists (idempotent)", func(t *testing.T) { + t.Parallel() + + hotelMock := &mockHotelsRepositoryClerk{ + insertHotelFromClerkOrgFunc: func(ctx context.Context, clerkOrgID string, name string) (*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{ + insertHotelFromClerkOrgFunc: func(ctx context.Context, clerkOrgID string, name string) (*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 := "550e8400-e29b-41d4-a716-446655440000" + + 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{ + findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + return &models.Hotel{ + ID: hotelID, + CreateHotelRequest: models.CreateHotelRequest{Name: "Hotel California"}, + }, nil }, } @@ -132,101 +277,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 503 when hotel not found so clerk retries", 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{ + findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID 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, 503, 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) + h := handler.NewClerkWebHookHandler(&mockUsersRepositoryClerk{}, &mockHotelsRepositoryClerk{}, validVerifier()) + app.Post("/webhook", h.CreateOrgMembership) - invalidPayload := `{ - "type": "user.created", - "data": { - "id": "", - "first_name": "", - "last_name": "" - } - }` - - 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{ + findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + return &models.Hotel{ID: hotelID}, nil }, } @@ -237,107 +344,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{ + findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + return &models.Hotel{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{ + findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + return &models.Hotel{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) + h := handler.NewClerkWebHookHandler(userMock, hotelMock, verifierMock) + app.Post("/webhook", h.CreateOrgMembership) - payloadWithImage := `{ - "type": "user.created", - "data": { - "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)) + 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")) }) -} +} \ No newline at end of file From aca427f3f5b3c7dc77ee62cf8b14a06811c004db Mon Sep 17 00:00:00 2001 From: Manuel Torres Date: Sun, 5 Apr 2026 12:36:07 -0400 Subject: [PATCH 3/5] lint --- backend/internal/handler/clerk.go | 1 - backend/internal/handler/hotels_test.go | 4 ++-- backend/internal/handler/users_test.go | 2 +- backend/internal/models/users.go | 2 +- backend/internal/repository/hotels.go | 2 +- backend/internal/service/clerk/organizations.go | 8 ++++---- backend/internal/tests/clerk_test.go | 8 +++----- 7 files changed, 12 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/clerk.go b/backend/internal/handler/clerk.go index 2c2900a99..1de7b3b43 100644 --- a/backend/internal/handler/clerk.go +++ b/backend/internal/handler/clerk.go @@ -72,7 +72,6 @@ func (h *ClerkWebHookHandler) CreateOrgMembership(c *fiber.Ctx) error { 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 { diff --git a/backend/internal/handler/hotels_test.go b/backend/internal/handler/hotels_test.go index 0a55e8e81..b865a68bb 100644 --- a/backend/internal/handler/hotels_test.go +++ b/backend/internal/handler/hotels_test.go @@ -31,11 +31,11 @@ func (m *mockHotelsRepository) InsertHotel(ctx context.Context, hotel *models.Cr } func (m *mockHotelsRepository) FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { - return nil, nil + return nil, nil } func (m *mockHotelsRepository) InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { - return nil, nil + return nil, nil } func TestHotelHandler_GetHotelByID(t *testing.T) { diff --git a/backend/internal/handler/users_test.go b/backend/internal/handler/users_test.go index e6d74028a..5257b738d 100644 --- a/backend/internal/handler/users_test.go +++ b/backend/internal/handler/users_test.go @@ -379,7 +379,7 @@ func TestUsersHandler_CreateUser(t *testing.T) { "timezone": "Invalid/Not_A_Timezone", "hotel_id": "550e8400-e29b-41d4-a716-446655440000" }` - + req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(invalidTimezoneBody)) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go index 78ef600da..021eaa1fd 100644 --- a/backend/internal/models/users.go +++ b/backend/internal/models/users.go @@ -45,7 +45,7 @@ type OrgMembershipData struct { } type ClerkOrganization struct { - ID string `json:"id"` + ID string `json:"id"` Name string `json:"name"` } diff --git a/backend/internal/repository/hotels.go b/backend/internal/repository/hotels.go index 10f6dd744..a25afc0c0 100644 --- a/backend/internal/repository/hotels.go +++ b/backend/internal/repository/hotels.go @@ -97,4 +97,4 @@ func (r *HotelsRepository) InsertHotelFromClerkOrg(ctx context.Context, clerkOrg return nil, err } return &hotel, nil -} \ No newline at end of file +} diff --git a/backend/internal/service/clerk/organizations.go b/backend/internal/service/clerk/organizations.go index 335d80e4b..4790b4e7d 100644 --- a/backend/internal/service/clerk/organizations.go +++ b/backend/internal/service/clerk/organizations.go @@ -18,13 +18,13 @@ func CreateClerkOrg(ctx context.Context, name string, createdByUserID string, ho raw := json.RawMessage(metadata) org, err := organization.Create(ctx, &organization.CreateParams{ - Name: clerksdk.String(name), - CreatedBy: clerksdk.String(createdByUserID), - PublicMetadata: &raw, + Name: clerksdk.String(name), + CreatedBy: clerksdk.String(createdByUserID), + PublicMetadata: &raw, }) if err != nil { return "", err } return org.ID, nil -} \ No newline at end of file +} diff --git a/backend/internal/tests/clerk_test.go b/backend/internal/tests/clerk_test.go index 07b36687a..6874b8ddd 100644 --- a/backend/internal/tests/clerk_test.go +++ b/backend/internal/tests/clerk_test.go @@ -18,7 +18,6 @@ import ( "github.com/stretchr/testify/require" ) - type mockWebhookVerifier struct { verifyFunc func(payload []byte, headers http.Header) error } @@ -59,7 +58,7 @@ func (m *mockUsersRepositoryClerk) SearchUsersByHotel(ctx context.Context, hotel var _ storage.UsersRepository = (*mockUsersRepositoryClerk)(nil) type mockHotelsRepositoryClerk struct { - findByClerkOrgIDFunc func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) + findByClerkOrgIDFunc func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) insertHotelFromClerkOrgFunc func(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) } @@ -84,7 +83,6 @@ func (m *mockHotelsRepositoryClerk) InsertHotelFromClerkOrg(ctx context.Context, var _ storage.HotelsRepository = (*mockHotelsRepositoryClerk)(nil) - func validVerifier() *mockWebhookVerifier { return &mockWebhookVerifier{ verifyFunc: func(payload []byte, headers http.Header) error { return nil }, @@ -259,7 +257,7 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { hotelMock := &mockHotelsRepositoryClerk{ findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { return &models.Hotel{ - ID: hotelID, + ID: hotelID, CreateHotelRequest: models.CreateHotelRequest{Name: "Hotel California"}, }, nil }, @@ -442,4 +440,4 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { assert.Equal(t, "1234567890", capturedHeaders.Get("svix-timestamp")) assert.Equal(t, "v1,signature123", capturedHeaders.Get("svix-signature")) }) -} \ No newline at end of file +} From 1ef7bbff25734224a95b601a5728d572b5f14e14 Mon Sep 17 00:00:00 2001 From: Manuel Torres Date: Sun, 5 Apr 2026 13:13:42 -0400 Subject: [PATCH 4/5] slight refactor --- backend/internal/handler/clerk.go | 8 ++++++- .../internal/service/clerk/organizations.go | 16 +++---------- backend/internal/utils/clerk.go | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 backend/internal/utils/clerk.go diff --git a/backend/internal/handler/clerk.go b/backend/internal/handler/clerk.go index 1de7b3b43..0753a3d72 100644 --- a/backend/internal/handler/clerk.go +++ b/backend/internal/handler/clerk.go @@ -2,12 +2,14 @@ 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" ) @@ -83,7 +85,7 @@ func (h *ClerkWebHookHandler) OrgCreated(c *fiber.Ctx) error { return errs.InvalidJSON() } - _, err := h.HotelsRepository.InsertHotelFromClerkOrg(c.Context(), payload.Data.ID, payload.Data.Name) + hotel, err := h.HotelsRepository.InsertHotelFromClerkOrg(c.Context(), payload.Data.ID, payload.Data.Name) if err != nil { if errors.Is(err, errs.ErrAlreadyExistsInDB) { return c.SendStatus(fiber.StatusOK) @@ -91,5 +93,9 @@ func (h *ClerkWebHookHandler) OrgCreated(c *fiber.Ctx) error { return errs.InternalServerError() } + 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/service/clerk/organizations.go b/backend/internal/service/clerk/organizations.go index 4790b4e7d..0eb58027b 100644 --- a/backend/internal/service/clerk/organizations.go +++ b/backend/internal/service/clerk/organizations.go @@ -2,25 +2,15 @@ package clerk import ( "context" - "encoding/json" 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, hotelID string) (string, error) { - metadata, err := json.Marshal(map[string]string{ - "hotel_id": hotelID, - }) - if err != nil { - return "", err - } - - raw := json.RawMessage(metadata) +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), - PublicMetadata: &raw, + Name: clerksdk.String(name), + CreatedBy: clerksdk.String(createdByUserID), }) if err != nil { return "", err 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 +} From 1eb93af8721115356c1a4c11705f095089cb9e54 Mon Sep 17 00:00:00 2001 From: Manuel Torres Date: Tue, 7 Apr 2026 14:10:20 -0400 Subject: [PATCH 5/5] cmts --- backend/internal/handler/clerk.go | 9 +- backend/internal/handler/hotels.go | 8 +- backend/internal/handler/hotels_test.go | 249 +++++++----------- backend/internal/models/clerk.go | 31 +++ backend/internal/models/hotels.go | 7 +- backend/internal/models/users.go | 30 --- backend/internal/repository/hotels.go | 61 +---- .../service/storage/postgres/repo_types.go | 2 - backend/internal/tests/clerk_test.go | 65 +++-- ...20260315000000_hotels-add-clerk-org-id.sql | 1 - 10 files changed, 174 insertions(+), 289 deletions(-) create mode 100644 backend/internal/models/clerk.go delete mode 100644 backend/supabase/migrations/20260315000000_hotels-add-clerk-org-id.sql diff --git a/backend/internal/handler/clerk.go b/backend/internal/handler/clerk.go index 0753a3d72..d5e73c4e4 100644 --- a/backend/internal/handler/clerk.go +++ b/backend/internal/handler/clerk.go @@ -57,10 +57,10 @@ func (h *ClerkWebHookHandler) CreateOrgMembership(c *fiber.Ctx) error { return errs.InvalidJSON() } - hotel, err := h.HotelsRepository.FindByClerkOrgID(c.Context(), payload.Data.Organization.ID) + hotel, err := h.HotelsRepository.FindByID(c.Context(), payload.Data.Organization.ID) if err != nil { if errors.Is(err, errs.ErrNotFoundInDB) { - return c.SendStatus(fiber.StatusServiceUnavailable) + return errs.NotFound("hotel", "id", payload.Data.Organization.ID) } return errs.InternalServerError() } @@ -85,7 +85,10 @@ func (h *ClerkWebHookHandler) OrgCreated(c *fiber.Ctx) error { return errs.InvalidJSON() } - hotel, err := h.HotelsRepository.InsertHotelFromClerkOrg(c.Context(), payload.Data.ID, payload.Data.Name) + 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) 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 b865a68bb..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) @@ -30,14 +29,6 @@ func (m *mockHotelsRepository) InsertHotel(ctx context.Context, hotel *models.Cr return m.insertHotelFunc(ctx, hotel) } -func (m *mockHotelsRepository) FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { - return nil, nil -} - -func (m *mockHotelsRepository) InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { - return nil, nil -} - func TestHotelHandler_GetHotelByID(t *testing.T) { t.Parallel() @@ -46,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() @@ -96,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) @@ -113,29 +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, - "clerk_org_id": "org_123" + "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)) @@ -146,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") }) @@ -154,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`)) @@ -177,30 +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 := `{ - "floors": 10, - "clerk_org_id": "org_123" -}` - - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(missingNameBody)) + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "name": "The Grand Budapest Hotel", + "floors": 10 + }`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) @@ -208,34 +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": "", - "floors": 10, - "clerk_org_id": "org_123" -}` + 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") + }) + + t.Run("returns 400 on missing name", func(t *testing.T) { + t.Parallel() - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(emptyNameBody)) + 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) @@ -246,30 +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", - "clerk_org_id": "org_123" -}` - - 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) @@ -277,34 +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 := `{ - "name": "The Grand Budapest Hotel", - "floors": 0, - "clerk_org_id": "org_123" -}` - - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(zeroFloorsBody)) + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "id": "org_2abc123", + "name": "The Grand Budapest Hotel", + "floors": -1 + }`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) @@ -315,31 +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 := `{ - "name": "The Grand Budapest Hotel", - "floors": -1, - "clerk_org_id": "org_123" -}` - - req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(negativeFloorsBody)) + req := httptest.NewRequest("POST", "/hotels", bytes.NewBufferString(`{ + "id": "org_2abc123", + "name": "The Grand Budapest Hotel", + "floors": 0 + }`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) @@ -350,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/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 1f25588e6..412cf04d7 100644 --- a/backend/internal/models/hotels.go +++ b/backend/internal/models/hotels.go @@ -3,13 +3,12 @@ package models import "time" type CreateHotelRequest struct { - Name string `json:"name" validate:"notblank" example:"Hotel California"` - Floors int `json:"floors" validate:"gte=1" example:"10"` - ClerkOrgID string `json:"clerk_org_id" validate:"notblank" example:"org_2abc123"` + ID string `json:"id" validate:"notblank" example:"org_2abc123"` + Name string `json:"name" validate:"notblank" example:"Hotel California"` + Floors *int `json:"floors,omitempty" validate:"omitempty,gte=1" example:"10"` } //@name CreateHotelRequest type Hotel struct { - ID string `json:"id" example:"550e8400-e29b-41d4-a716-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 021eaa1fd..7ac4e6fbd 100644 --- a/backend/internal/models/users.go +++ b/backend/internal/models/users.go @@ -33,36 +33,6 @@ type ClerkUser struct { HasImage bool `json:"has_image" example:"true"` } -// 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"` -} - type User struct { CreateUser CreatedAt time.Time `json:"created_at" example:"2024-01-01T00:00:00Z"` diff --git a/backend/internal/repository/hotels.go b/backend/internal/repository/hotels.go index a25afc0c0..0f75fb295 100644 --- a/backend/internal/repository/hotels.go +++ b/backend/internal/repository/hotels.go @@ -20,13 +20,13 @@ 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, clerk_org_id, created_at, updated_at + SELECT id, name, floors, created_at, updated_at FROM hotels WHERE id = $1 `, id) var hotel models.Hotel - err := row.Scan(&hotel.ID, &hotel.Name, &hotel.Floors, &hotel.ClerkOrgID, &hotel.CreatedAt, &hotel.UpdatedAt) + err := row.Scan(&hotel.ID, &hotel.Name, &hotel.Floors, &hotel.CreatedAt, &hotel.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.ErrNotFoundInDB @@ -39,56 +39,13 @@ 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, clerk_org_id - ) VALUES ( - $1, $2, $3 - ) - RETURNING id, created_at, updated_at - `, - hotel.Name, - hotel.Floors, - hotel.ClerkOrgID, - ).Scan(&createdHotel.ID, &createdHotel.CreatedAt, &createdHotel.UpdatedAt) - - if err != nil { - return nil, err - } - - return createdHotel, nil -} - -func (r *HotelsRepository) FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { - row := r.db.QueryRow(ctx, ` - SELECT id, name, floors, clerk_org_id, created_at, updated_at - FROM hotels - WHERE clerk_org_id = $1 - `, clerkOrgID) - - var hotel models.Hotel - err := row.Scan(&hotel.ID, &hotel.Name, &hotel.Floors, &hotel.ClerkOrgID, &hotel.CreatedAt, &hotel.UpdatedAt) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, errs.ErrNotFoundInDB - } - return nil, err - } - - return &hotel, nil -} - -func (r *HotelsRepository) InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { - var hotel models.Hotel - err := r.db.QueryRow(ctx, ` - INSERT INTO hotels (name, clerk_org_id) - VALUES ($1, $2) - ON CONFLICT (clerk_org_id) DO NOTHING - RETURNING id, name, floors, clerk_org_id, created_at, updated_at - `, name, clerkOrgID).Scan( - &hotel.ID, &hotel.Name, &hotel.Floors, - &hotel.ClerkOrgID, &hotel.CreatedAt, &hotel.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) { @@ -96,5 +53,5 @@ func (r *HotelsRepository) InsertHotelFromClerkOrg(ctx context.Context, clerkOrg } return nil, err } - return &hotel, nil + return createdHotel, nil } diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 53e2a40b3..1a631e4f9 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -56,8 +56,6 @@ type RequestsRepository interface { type HotelsRepository interface { FindByID(ctx context.Context, id string) (*models.Hotel, error) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) - FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) - InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) } // S3Storage defines the interface for S3 operations diff --git a/backend/internal/tests/clerk_test.go b/backend/internal/tests/clerk_test.go index 6874b8ddd..7c3fabda4 100644 --- a/backend/internal/tests/clerk_test.go +++ b/backend/internal/tests/clerk_test.go @@ -58,25 +58,19 @@ func (m *mockUsersRepositoryClerk) SearchUsersByHotel(ctx context.Context, hotel var _ storage.UsersRepository = (*mockUsersRepositoryClerk)(nil) type mockHotelsRepositoryClerk struct { - findByClerkOrgIDFunc func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) - insertHotelFromClerkOrgFunc func(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) + 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) { - return nil, nil -} -func (m *mockHotelsRepositoryClerk) InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { - return nil, nil -} -func (m *mockHotelsRepositoryClerk) FindByClerkOrgID(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { - if m.findByClerkOrgIDFunc != nil { - return m.findByClerkOrgIDFunc(ctx, clerkOrgID) + if m.findByIDFunc != nil { + return m.findByIDFunc(ctx, id) } return nil, nil } -func (m *mockHotelsRepositoryClerk) InsertHotelFromClerkOrg(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { - if m.insertHotelFromClerkOrgFunc != nil { - return m.insertHotelFromClerkOrgFunc(ctx, clerkOrgID, name) +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 } @@ -132,12 +126,11 @@ func TestClerkHandler_OrgCreated(t *testing.T) { t.Run("returns 200 and creates hotel when org is created", func(t *testing.T) { t.Parallel() - var capturedOrgID, capturedName string + var capturedReq *models.CreateHotelRequest hotelMock := &mockHotelsRepositoryClerk{ - insertHotelFromClerkOrgFunc: func(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { - capturedOrgID = clerkOrgID - capturedName = name - return &models.Hotel{}, nil + insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { + capturedReq = hotel + return &models.Hotel{CreateHotelRequest: *hotel}, nil }, } @@ -151,15 +144,15 @@ func TestClerkHandler_OrgCreated(t *testing.T) { resp, err := app.Test(req) require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) - assert.Equal(t, "org_123", capturedOrgID) - assert.Equal(t, "Hotel California", capturedName) + 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{ - insertHotelFromClerkOrgFunc: func(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { + insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { return nil, errs.ErrAlreadyExistsInDB }, } @@ -195,7 +188,7 @@ func TestClerkHandler_OrgCreated(t *testing.T) { t.Parallel() hotelMock := &mockHotelsRepositoryClerk{ - insertHotelFromClerkOrgFunc: func(ctx context.Context, clerkOrgID string, name string) (*models.Hotel, error) { + insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { return nil, errors.New("db error") }, } @@ -232,7 +225,7 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { } }` - hotelID := "550e8400-e29b-41d4-a716-446655440000" + hotelID := "org_123" t.Run("returns 401 when signature verification fails", func(t *testing.T) { t.Parallel() @@ -255,10 +248,12 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { var capturedUser *models.CreateUser hotelMock := &mockHotelsRepositoryClerk{ - findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { return &models.Hotel{ - ID: hotelID, - CreateHotelRequest: models.CreateHotelRequest{Name: "Hotel California"}, + CreateHotelRequest: models.CreateHotelRequest{ + ID: hotelID, + Name: "Hotel California", + }, }, nil }, } @@ -290,11 +285,11 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { assert.Equal(t, hotelID, capturedUser.HotelID) }) - t.Run("returns 503 when hotel not found so clerk retries", func(t *testing.T) { + t.Run("returns 404 when hotel not found", func(t *testing.T) { t.Parallel() hotelMock := &mockHotelsRepositoryClerk{ - findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { return nil, errs.ErrNotFoundInDB }, } @@ -308,7 +303,7 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { resp, err := app.Test(req) require.NoError(t, err) - assert.Equal(t, 503, resp.StatusCode) + assert.Equal(t, 404, resp.StatusCode) }) t.Run("returns 400 when payload is invalid JSON", func(t *testing.T) { @@ -330,8 +325,8 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { t.Parallel() hotelMock := &mockHotelsRepositoryClerk{ - findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { - return &models.Hotel{ID: hotelID}, nil + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: models.CreateHotelRequest{ID: hotelID}}, nil }, } @@ -359,8 +354,8 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { var capturedUser *models.CreateUser hotelMock := &mockHotelsRepositoryClerk{ - findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { - return &models.Hotel{ID: hotelID}, nil + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: models.CreateHotelRequest{ID: hotelID}}, nil }, } @@ -412,8 +407,8 @@ func TestClerkHandler_CreateOrgMembership(t *testing.T) { } hotelMock := &mockHotelsRepositoryClerk{ - findByClerkOrgIDFunc: func(ctx context.Context, clerkOrgID string) (*models.Hotel, error) { - return &models.Hotel{ID: hotelID}, nil + findByIDFunc: func(ctx context.Context, id string) (*models.Hotel, error) { + return &models.Hotel{CreateHotelRequest: models.CreateHotelRequest{ID: hotelID}}, nil }, } diff --git a/backend/supabase/migrations/20260315000000_hotels-add-clerk-org-id.sql b/backend/supabase/migrations/20260315000000_hotels-add-clerk-org-id.sql deleted file mode 100644 index 07f077fb4..000000000 --- a/backend/supabase/migrations/20260315000000_hotels-add-clerk-org-id.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public.hotels ADD COLUMN clerk_org_id text UNIQUE;