From 05d335c8c7fb5bc218808a2af4eacb80877ef5af Mon Sep 17 00:00:00 2001 From: Manuel Torres Date: Thu, 9 Apr 2026 21:25:19 -0400 Subject: [PATCH 1/4] Revert "Revert "hotel id header injection - MERGE AFTER ONBOARDING/CLERK ORGS (#287)" (#294)" This reverts commit 4c81c421e17b6e366baa0bb7c95f5ea80ebf1bf3. --- clients/mobile/app/_layout.tsx | 18 ++++++++++++++++-- clients/mobile/app/no-org.tsx | 11 +++++++++++ clients/shared/src/api/client.ts | 8 ++++---- clients/shared/src/api/config.ts | 1 + clients/shared/src/api/orval-mutator.ts | 4 ++-- clients/web/src/routeTree.gen.ts | 21 +++++++++++++++++++++ clients/web/src/routes/__root.tsx | 24 +++++++++++++++++++++--- clients/web/src/routes/no-org.tsx | 17 +++++++++++++++++ 8 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 clients/mobile/app/no-org.tsx create mode 100644 clients/web/src/routes/no-org.tsx diff --git a/clients/mobile/app/_layout.tsx b/clients/mobile/app/_layout.tsx index 31940a2f4..de19cb90b 100644 --- a/clients/mobile/app/_layout.tsx +++ b/clients/mobile/app/_layout.tsx @@ -1,4 +1,4 @@ -import { Stack } from "expo-router"; +import { Stack, Redirect } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -7,7 +7,13 @@ import "../global.css"; import { DefaultTheme, ThemeProvider } from "@react-navigation/native"; import { tokenCache } from "@clerk/clerk-expo/token-cache"; -import { ClerkProvider, ClerkLoaded, useAuth } from "@clerk/clerk-expo"; +import { + ClerkProvider, + ClerkLoaded, + useAuth, + useOrganization, +} from "@clerk/clerk-expo"; +import { useColorScheme } from "@/hooks/use-color-scheme"; import { setConfig } from "@shared"; // Client explicity created outside component to avoid recreation @@ -28,9 +34,17 @@ export const unstable_settings = { // Component to configure auth provider and the api base url function AppConfigurator() { const { getToken } = useAuth(); + const { organization } = useOrganization(); + const hotelId = organization?.publicMetadata?.hotel_id; + + if (!hotelId) { + return ; + } + setConfig({ API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL ?? "", getToken, + hotelId: hotelId as string, }); return null; } diff --git a/clients/mobile/app/no-org.tsx b/clients/mobile/app/no-org.tsx new file mode 100644 index 000000000..137b7b7ac --- /dev/null +++ b/clients/mobile/app/no-org.tsx @@ -0,0 +1,11 @@ +import { View, Text } from "react-native"; + +export default function NoOrg() { + return ( + + + No Organization Found + + + ); +} diff --git a/clients/shared/src/api/client.ts b/clients/shared/src/api/client.ts index 648ae378f..4da8b4fbf 100644 --- a/clients/shared/src/api/client.ts +++ b/clients/shared/src/api/client.ts @@ -7,8 +7,8 @@ import { getConfig } from "./config"; export const createRequest = ( getToken: () => Promise, baseUrl: string, + hotelId: string, ) => { - const hardCodedHotelId = "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" return async (config: RequestConfig): Promise => { let fullUrl = `${baseUrl}${config.url}`; if (config.params && Object.keys(config.params).length > 0) { @@ -24,7 +24,7 @@ export const createRequest = ( headers: { "Content-Type": "application/json", ...(token && { Authorization: `Bearer ${token}` }), - "X-Hotel-ID": hardCodedHotelId, + "X-Hotel-ID": hotelId, ...config.headers, }, body: config.data ? JSON.stringify(config.data) : undefined, @@ -74,9 +74,9 @@ export const useAPIClient = (): HttpClient => { // can be called during app startup (e.g. in a useEffect) // before any API calls are executed. const request = async (config: RequestConfig): Promise => { - const { getToken } = getConfig(); + const { getToken, hotelId } = getConfig(); const baseUrl = getBaseUrl(); - const doRequest = createRequest(getToken, baseUrl); + const doRequest = createRequest(getToken, baseUrl, hotelId); return doRequest(config); }; diff --git a/clients/shared/src/api/config.ts b/clients/shared/src/api/config.ts index fee2aae85..12cb60820 100644 --- a/clients/shared/src/api/config.ts +++ b/clients/shared/src/api/config.ts @@ -8,6 +8,7 @@ export type Config = { API_BASE_URL: string getToken: () => Promise + hotelId: string } let config: Config | null = null diff --git a/clients/shared/src/api/orval-mutator.ts b/clients/shared/src/api/orval-mutator.ts index 927ca8e5e..ad0d90a55 100755 --- a/clients/shared/src/api/orval-mutator.ts +++ b/clients/shared/src/api/orval-mutator.ts @@ -10,8 +10,8 @@ import { RequestConfig } from "../types/api.types"; export const useCustomInstance = (): (( config: RequestConfig, ) => Promise) => { - const { getToken } = getConfig(); - const request = createRequest(getToken, getBaseUrl()); + const { getToken, hotelId } = getConfig(); + const request = createRequest(getToken, getBaseUrl(), hotelId); return async (config: RequestConfig): Promise => { const response = await request(config); diff --git a/clients/web/src/routeTree.gen.ts b/clients/web/src/routeTree.gen.ts index 385331118..d03f3cd37 100644 --- a/clients/web/src/routeTree.gen.ts +++ b/clients/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SignUpRouteImport } from './routes/sign-up' import { Route as SignInRouteImport } from './routes/sign-in' +import { Route as NoOrgRouteImport } from './routes/no-org' import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as IndexRouteImport } from './routes/index' import { Route as ProtectedTestApiRouteImport } from './routes/_protected/test-api' @@ -32,6 +33,11 @@ const SignInRoute = SignInRouteImport.update({ path: '/sign-in', getParentRoute: () => rootRouteImport, } as any) +const NoOrgRoute = NoOrgRouteImport.update({ + id: '/no-org', + path: '/no-org', + getParentRoute: () => rootRouteImport, +} as any) const ProtectedRoute = ProtectedRouteImport.update({ id: '/_protected', getParentRoute: () => rootRouteImport, @@ -84,6 +90,7 @@ const ProtectedGuestsGuestIdRoute = ProtectedGuestsGuestIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/no-org': typeof NoOrgRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute '/home': typeof ProtectedHomeRoute @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/no-org': typeof NoOrgRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute '/home': typeof ProtectedHomeRoute @@ -111,6 +119,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/_protected': typeof ProtectedRouteWithChildren + '/no-org': typeof NoOrgRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute '/_protected/home': typeof ProtectedHomeRoute @@ -126,6 +135,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/no-org' | '/sign-in' | '/sign-up' | '/home' @@ -139,6 +149,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/no-org' | '/sign-in' | '/sign-up' | '/home' @@ -152,6 +163,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/_protected' + | '/no-org' | '/sign-in' | '/sign-up' | '/_protected/home' @@ -167,6 +179,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ProtectedRoute: typeof ProtectedRouteWithChildren + NoOrgRoute: typeof NoOrgRoute SignInRoute: typeof SignInRoute SignUpRoute: typeof SignUpRoute } @@ -187,6 +200,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SignInRouteImport parentRoute: typeof rootRouteImport } + '/no-org': { + id: '/no-org' + path: '/no-org' + fullPath: '/no-org' + preLoaderRoute: typeof NoOrgRouteImport + parentRoute: typeof rootRouteImport + } '/_protected': { id: '/_protected' path: '' @@ -299,6 +319,7 @@ const ProtectedRouteWithChildren = ProtectedRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ProtectedRoute: ProtectedRouteWithChildren, + NoOrgRoute: NoOrgRoute, SignInRoute: SignInRoute, SignUpRoute: SignUpRoute, } diff --git a/clients/web/src/routes/__root.tsx b/clients/web/src/routes/__root.tsx index db64dc9a1..541323965 100644 --- a/clients/web/src/routes/__root.tsx +++ b/clients/web/src/routes/__root.tsx @@ -1,7 +1,12 @@ -import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; +import { + HeadContent, + Scripts, + createRootRoute, + useNavigate, +} from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { TanStackDevtools } from "@tanstack/react-devtools"; -import { ClerkProvider, useAuth } from "@clerk/clerk-react"; +import { ClerkProvider, useAuth, useOrganization } from "@clerk/clerk-react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { setConfig } from "@shared"; @@ -60,8 +65,21 @@ export const Route = createRootRoute({ // Component to configure auth provider and the api base url function AppConfigurator() { const { getToken } = useAuth(); + const { organization } = useOrganization(); + const hotelId = organization?.publicMetadata.hotel_id; + + const navigate = useNavigate(); + + if (!hotelId) { + navigate({ to: "/no-org" }); + } + useEffect(() => { - setConfig({ API_BASE_URL: process.env.API_BASE_URL ?? "", getToken }); + setConfig({ + API_BASE_URL: process.env.API_BASE_URL ?? "", + getToken, + hotelId: hotelId as string, + }); }, [getToken]); return null; diff --git a/clients/web/src/routes/no-org.tsx b/clients/web/src/routes/no-org.tsx new file mode 100644 index 000000000..6b91bdc58 --- /dev/null +++ b/clients/web/src/routes/no-org.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/no-org")({ + component: NoOrgPage, +}); + +function NoOrgPage() { + return ( +
+

No Organization Found

+

+ You're not part of a hotel organization yet. Please contact your manager + for an invitation. +

+
+ ); +} From 74b447293f4c0dc686d18de606dfb45176226dae Mon Sep 17 00:00:00 2001 From: Manuel Torres Date: Thu, 9 Apr 2026 21:29:33 -0400 Subject: [PATCH 2/4] things --- backend/cmd/clerk/sync_test.go | 200 ------------------- backend/cmd/cli/main.go | 111 ---------- backend/cmd/{clerk/sync.go => cli/users.go} | 37 +++- backend/internal/models/users.go | 17 +- backend/internal/service/clerk/sync-users.go | 15 +- 5 files changed, 39 insertions(+), 341 deletions(-) delete mode 100644 backend/cmd/clerk/sync_test.go delete mode 100644 backend/cmd/cli/main.go rename backend/cmd/{clerk/sync.go => cli/users.go} (52%) diff --git a/backend/cmd/clerk/sync_test.go b/backend/cmd/clerk/sync_test.go deleted file mode 100644 index bf99dfa94..000000000 --- a/backend/cmd/clerk/sync_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package main - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/generate/selfserve/internal/models" - storage "github.com/generate/selfserve/internal/service/storage/postgres" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type mockUsersRepositorySync struct { - bulkInsertFunc func(ctx context.Context, users []*models.CreateUser) error -} - -func (m *mockUsersRepositorySync) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) { - return nil, nil -} - -func (m *mockUsersRepositorySync) BulkInsertUsers(ctx context.Context, users []*models.CreateUser) error { - return m.bulkInsertFunc(ctx, users) -} - -func (m *mockUsersRepositorySync) FindUser(ctx context.Context, id string) (*models.User, error) { - return nil, nil -} - -func (m *mockUsersRepositorySync) UpdateProfilePicture(ctx context.Context, userId string, key string) error { - return nil -} - -func (m *mockUsersRepositorySync) DeleteProfilePicture(ctx context.Context, userId string) error { - return nil -} - -func (m *mockUsersRepositorySync) GetKey(ctx context.Context, userId string) (string, error) { - return "", nil -} - -func (m *mockUsersRepositorySync) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { - return nil, nil -} - -func (m *mockUsersRepositorySync) 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 = (*mockUsersRepositorySync)(nil) - -func TestSyncUsers(t *testing.T) { - t.Parallel() - - t.Run("successfully syncs valid users from Clerk", func(t *testing.T) { - t.Parallel() - - var capturedUsers []*models.CreateUser - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Contains(t, r.Header.Get("Authorization"), "Bearer ") - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[ - { - "id": "user_123", - "first_name": "John", - "last_name": "Doe", - "has_image": false, - "image_url": null - }, - { - "id": "user_456", - "first_name": "Jane", - "last_name": "Smith", - "has_image": true, - "image_url": "https://example.com/jane.jpg" - } - ]`)) - })) - defer server.Close() - - userMock := &mockUsersRepositorySync{ - bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { - capturedUsers = users - return nil - }, - } - - err := syncUsers(context.Background(), server.URL, "test_secret", userMock) - require.NoError(t, err) - - assert.Len(t, capturedUsers, 2) - assert.Equal(t, "user_123", capturedUsers[0].ID) - assert.Equal(t, "John", capturedUsers[0].FirstName) - assert.Equal(t, "Doe", capturedUsers[0].LastName) - assert.Nil(t, capturedUsers[0].ProfilePicture) - assert.Equal(t, "user_456", capturedUsers[1].ID) - assert.Equal(t, "Jane", capturedUsers[1].FirstName) - assert.Equal(t, "Smith", capturedUsers[1].LastName) - assert.Equal(t, "https://example.com/jane.jpg", *capturedUsers[1].ProfilePicture) - }) - - t.Run("returns error when Clerk API fails", func(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer server.Close() - - userMock := &mockUsersRepositorySync{ - bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { - return nil - }, - } - - err := syncUsers(context.Background(), server.URL, "bad_secret", userMock) - require.Error(t, err) - }) - - t.Run("returns error when validation fails", func(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[ - { - "id": "", - "first_name": "", - "last_name": "", - "has_image": false - } - ]`)) - })) - defer server.Close() - - userMock := &mockUsersRepositorySync{ - bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { - return nil - }, - } - - err := syncUsers(context.Background(), server.URL, "test_secret", userMock) - require.Error(t, err) - }) - - t.Run("returns error when bulk insert fails", func(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[ - { - "id": "user_123", - "first_name": "John", - "last_name": "Doe", - "has_image": false - } - ]`)) - })) - defer server.Close() - - userMock := &mockUsersRepositorySync{ - bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { - return errors.New("db connection failed") - }, - } - - err := syncUsers(context.Background(), server.URL, "test_secret", userMock) - require.Error(t, err) - assert.Contains(t, err.Error(), "db connection failed") - }) - - t.Run("handles empty user list from Clerk", func(t *testing.T) { - t.Parallel() - - var insertCalled bool - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`[]`)) - })) - defer server.Close() - - userMock := &mockUsersRepositorySync{ - bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { - insertCalled = true - return nil - }, - } - - err := syncUsers(context.Background(), server.URL, "test_secret", userMock) - require.NoError(t, err) - assert.True(t, insertCalled) - }) -} diff --git a/backend/cmd/cli/main.go b/backend/cmd/cli/main.go deleted file mode 100644 index 79b261fa2..000000000 --- a/backend/cmd/cli/main.go +++ /dev/null @@ -1,111 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - "os" - - "github.com/generate/selfserve/config" - "github.com/generate/selfserve/internal/repository" - "github.com/generate/selfserve/internal/service/clerk" - storage "github.com/generate/selfserve/internal/service/storage/postgres" - "github.com/sethvargo/go-envconfig" -) - -// command is a runnable CLI subcommand. -type command struct { - description string - run func(ctx context.Context, cfg config.Config, args []string) error -} - -// commands is the registry of all available CLI subcommands. -// To add a new command: add an entry here and implement its run func below. -// -// "my-new-command": { -// description: "What this command does", -// run: runMyNewCommand, -// }, -var commands = map[string]command{ - "sync-users": { - description: "Sync users from Clerk into the database", - run: runSyncUsers, - }, - "reindex-guests": { - description: "Fetch all guests from the database and reindex them in OpenSearch", - run: runReindexGuests, - }, - "backfill-hotel-departments": { - description: "Seed default departments for hotels that have no departments", - run: runBackfillHotelDepartments, - }, -} - -func main() { - flag.Usage = printUsage - flag.Parse() - - args := flag.Args() - if len(args) == 0 { - printUsage() - os.Exit(1) - } - - name := args[0] - cmd, ok := commands[name] - if !ok { - fmt.Fprintf(os.Stderr, "unknown command: %q\n\n", name) - printUsage() - os.Exit(1) - } - - ctx := context.Background() - var cfg config.Config - if err := envconfig.Process(ctx, &cfg); err != nil { - log.Fatal("failed to process config:", err) - } - - if err := cmd.run(ctx, cfg, args[1:]); err != nil { - log.Fatalf("%s: %v", name, err) - } -} - -func printUsage() { - fmt.Fprintf(os.Stderr, "Usage: cli [args]\n\nAvailable commands:\n") - for name, cmd := range commands { - fmt.Fprintf(os.Stderr, " %-20s %s\n", name, cmd.description) - } - fmt.Fprintln(os.Stderr) -} - -// ============================================================================= -// Command implementations -// ============================================================================= - -func runSyncUsers(ctx context.Context, cfg config.Config, _ []string) error { - repo, err := storage.NewRepository(cfg.DB) - if err != nil { - return fmt.Errorf("failed to connect to db: %w", err) - } - defer repo.Close() - - usersRepo := repository.NewUsersRepository(repo.DB) - - users, err := clerk.FetchUsersFromClerk(cfg.BaseURL+"/users", cfg.SecretKey) - if err != nil { - return err - } - - transformed, err := clerk.ValidateAndReformatUserData(users) - if err != nil { - return err - } - - if err := usersRepo.BulkInsertUsers(ctx, transformed); err != nil { - return fmt.Errorf("failed to insert users: %w", err) - } - - fmt.Println("sync-users completed successfully") - return nil -} diff --git a/backend/cmd/clerk/sync.go b/backend/cmd/cli/users.go similarity index 52% rename from backend/cmd/clerk/sync.go rename to backend/cmd/cli/users.go index fecefbcad..4d57fcf7b 100644 --- a/backend/cmd/clerk/sync.go +++ b/backend/cmd/cli/users.go @@ -6,6 +6,7 @@ import ( "log" "github.com/generate/selfserve/config" + "github.com/generate/selfserve/internal/models" "github.com/generate/selfserve/internal/repository" "github.com/generate/selfserve/internal/service/clerk" storage "github.com/generate/selfserve/internal/service/storage/postgres" @@ -26,8 +27,7 @@ func main() { defer repo.Close() usersRepo := repository.NewUsersRepository(repo.DB) - path := "/users" - err = syncUsers(ctx, cfg.BaseURL+path, cfg.SecretKey, usersRepo) + err = syncUsers(ctx, cfg.BaseURL, cfg.SecretKey, usersRepo) if err != nil { log.Fatal(err) } @@ -37,19 +37,34 @@ func main() { func syncUsers(ctx context.Context, clerkBaseURL string, clerkSecret string, usersRepo storage.UsersRepository) error { - users, err := clerk.FetchUsersFromClerk(clerkBaseURL, clerkSecret) + users, err := clerk.FetchUsersFromClerk(clerkBaseURL+"/users", clerkSecret); if err != nil { - return err + return fmt.Errorf("failed to fetch users: %w", err); } - transformed, err := clerk.ValidateAndReformatUserData(users) - if err != nil { - return err - } + for _, u := range users { + if len(u.OrganizationMemberships) == 0 { + log.Printf("skipping user %s: no org membership found", u.ID); + continue; + } + + orgID := u.OrganizationMemberships[0].Organization.ID - if err := usersRepo.BulkInsertUsers(ctx, transformed); err != nil { - return fmt.Errorf("failed to insert users: %w", err) + createUser := &models.CreateUser{ + ID: u.ID, + FirstName: u.FirstName, + LastName: u.LastName, + HotelID: orgID, + ProfilePicture: u.ImageUrl, + } + + if _, err := usersRepo.InsertUser(ctx, createUser); err != nil { + log.Printf("failed to insert user %s: %v", u.ID, err); + continue; + } + + log.Printf("inserted user %s", u.ID); } return nil -} +} \ No newline at end of file diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go index 7ac4e6fbd..79c0f394a 100644 --- a/backend/internal/models/users.go +++ b/backend/internal/models/users.go @@ -25,12 +25,19 @@ type CreateUserWebhook struct { ClerkUser `json:"data"` } +type ClerkOrganizationMembership struct { + Organization struct { + ID string `json:"id"` + } `json:"organization"` +} + type ClerkUser struct { - ID string `json:"id" example:"user123402"` - FirstName string `json:"first_name" example:"John"` - LastName string `json:"last_name" example:"Doe"` - ImageUrl *string `json:"image_url" example:"https://photo.com/john.jpg"` - HasImage bool `json:"has_image" example:"true"` + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + ImageUrl *string `json:"image_url"` + HasImage bool `json:"has_image"` + OrganizationMemberships []ClerkOrganizationMembership `json:"organization_memberships"` } type User struct { diff --git a/backend/internal/service/clerk/sync-users.go b/backend/internal/service/clerk/sync-users.go index f7f7f8ee9..9d7c784cc 100644 --- a/backend/internal/service/clerk/sync-users.go +++ b/backend/internal/service/clerk/sync-users.go @@ -3,24 +3,11 @@ package clerk import ( "encoding/json" "net/http" - - "github.com/generate/selfserve/internal/handler" "github.com/generate/selfserve/internal/models" ) -func ValidateAndReformatUserData(users []models.ClerkUser) ([]*models.CreateUser, error) { - var reformatedUsers []*models.CreateUser - for _, user := range users { - if err := handler.ValidateCreateUserClerk(&user); err != nil { - return nil, err - } - reformatedUsers = append(reformatedUsers, handler.ReformatUserData(&user)) - } - return reformatedUsers, nil -} - func FetchUsersFromClerk(clerkApiUrl string, clerkSecret string) ([]models.ClerkUser, error) { - req, err := http.NewRequest("GET", clerkApiUrl, nil) + req, err := http.NewRequest("GET", clerkApiUrl+"?with_organization_memberships=true", nil) if err != nil { return nil, err } From 8b3b9fd9bec811bebb35c9cf522649c9fb33ac8d Mon Sep 17 00:00:00 2001 From: Manuel Torres Date: Thu, 9 Apr 2026 21:33:18 -0400 Subject: [PATCH 3/4] type --- clients/web/src/routes/__root.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/clients/web/src/routes/__root.tsx b/clients/web/src/routes/__root.tsx index a6cf38ba2..0d22603db 100644 --- a/clients/web/src/routes/__root.tsx +++ b/clients/web/src/routes/__root.tsx @@ -1,8 +1,7 @@ import { HeadContent, Scripts, - createRootRoute, - useNavigate, + createRootRoute } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { TanStackDevtools } from "@tanstack/react-devtools"; From 9d8cc72e96c109d9edf70077a163490ee2ed84be Mon Sep 17 00:00:00 2001 From: Manuel Torres Date: Thu, 9 Apr 2026 21:36:21 -0400 Subject: [PATCH 4/4] removing other pr changes --- backend/cmd/clerk/sync_test.go | 200 +++++++++++++++++++ backend/cmd/cli/main.go | 111 ++++++++++ backend/internal/models/users.go | 19 +- backend/internal/service/clerk/sync-users.go | 15 +- 4 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 backend/cmd/clerk/sync_test.go create mode 100644 backend/cmd/cli/main.go diff --git a/backend/cmd/clerk/sync_test.go b/backend/cmd/clerk/sync_test.go new file mode 100644 index 000000000..bf99dfa94 --- /dev/null +++ b/backend/cmd/clerk/sync_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockUsersRepositorySync struct { + bulkInsertFunc func(ctx context.Context, users []*models.CreateUser) error +} + +func (m *mockUsersRepositorySync) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) { + return nil, nil +} + +func (m *mockUsersRepositorySync) BulkInsertUsers(ctx context.Context, users []*models.CreateUser) error { + return m.bulkInsertFunc(ctx, users) +} + +func (m *mockUsersRepositorySync) FindUser(ctx context.Context, id string) (*models.User, error) { + return nil, nil +} + +func (m *mockUsersRepositorySync) UpdateProfilePicture(ctx context.Context, userId string, key string) error { + return nil +} + +func (m *mockUsersRepositorySync) DeleteProfilePicture(ctx context.Context, userId string) error { + return nil +} + +func (m *mockUsersRepositorySync) GetKey(ctx context.Context, userId string) (string, error) { + return "", nil +} + +func (m *mockUsersRepositorySync) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { + return nil, nil +} + +func (m *mockUsersRepositorySync) 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 = (*mockUsersRepositorySync)(nil) + +func TestSyncUsers(t *testing.T) { + t.Parallel() + + t.Run("successfully syncs valid users from Clerk", func(t *testing.T) { + t.Parallel() + + var capturedUsers []*models.CreateUser + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.Header.Get("Authorization"), "Bearer ") + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[ + { + "id": "user_123", + "first_name": "John", + "last_name": "Doe", + "has_image": false, + "image_url": null + }, + { + "id": "user_456", + "first_name": "Jane", + "last_name": "Smith", + "has_image": true, + "image_url": "https://example.com/jane.jpg" + } + ]`)) + })) + defer server.Close() + + userMock := &mockUsersRepositorySync{ + bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { + capturedUsers = users + return nil + }, + } + + err := syncUsers(context.Background(), server.URL, "test_secret", userMock) + require.NoError(t, err) + + assert.Len(t, capturedUsers, 2) + assert.Equal(t, "user_123", capturedUsers[0].ID) + assert.Equal(t, "John", capturedUsers[0].FirstName) + assert.Equal(t, "Doe", capturedUsers[0].LastName) + assert.Nil(t, capturedUsers[0].ProfilePicture) + assert.Equal(t, "user_456", capturedUsers[1].ID) + assert.Equal(t, "Jane", capturedUsers[1].FirstName) + assert.Equal(t, "Smith", capturedUsers[1].LastName) + assert.Equal(t, "https://example.com/jane.jpg", *capturedUsers[1].ProfilePicture) + }) + + t.Run("returns error when Clerk API fails", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + userMock := &mockUsersRepositorySync{ + bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { + return nil + }, + } + + err := syncUsers(context.Background(), server.URL, "bad_secret", userMock) + require.Error(t, err) + }) + + t.Run("returns error when validation fails", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[ + { + "id": "", + "first_name": "", + "last_name": "", + "has_image": false + } + ]`)) + })) + defer server.Close() + + userMock := &mockUsersRepositorySync{ + bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { + return nil + }, + } + + err := syncUsers(context.Background(), server.URL, "test_secret", userMock) + require.Error(t, err) + }) + + t.Run("returns error when bulk insert fails", func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[ + { + "id": "user_123", + "first_name": "John", + "last_name": "Doe", + "has_image": false + } + ]`)) + })) + defer server.Close() + + userMock := &mockUsersRepositorySync{ + bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { + return errors.New("db connection failed") + }, + } + + err := syncUsers(context.Background(), server.URL, "test_secret", userMock) + require.Error(t, err) + assert.Contains(t, err.Error(), "db connection failed") + }) + + t.Run("handles empty user list from Clerk", func(t *testing.T) { + t.Parallel() + + var insertCalled bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + + userMock := &mockUsersRepositorySync{ + bulkInsertFunc: func(ctx context.Context, users []*models.CreateUser) error { + insertCalled = true + return nil + }, + } + + err := syncUsers(context.Background(), server.URL, "test_secret", userMock) + require.NoError(t, err) + assert.True(t, insertCalled) + }) +} diff --git a/backend/cmd/cli/main.go b/backend/cmd/cli/main.go new file mode 100644 index 000000000..79b261fa2 --- /dev/null +++ b/backend/cmd/cli/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + + "github.com/generate/selfserve/config" + "github.com/generate/selfserve/internal/repository" + "github.com/generate/selfserve/internal/service/clerk" + storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/sethvargo/go-envconfig" +) + +// command is a runnable CLI subcommand. +type command struct { + description string + run func(ctx context.Context, cfg config.Config, args []string) error +} + +// commands is the registry of all available CLI subcommands. +// To add a new command: add an entry here and implement its run func below. +// +// "my-new-command": { +// description: "What this command does", +// run: runMyNewCommand, +// }, +var commands = map[string]command{ + "sync-users": { + description: "Sync users from Clerk into the database", + run: runSyncUsers, + }, + "reindex-guests": { + description: "Fetch all guests from the database and reindex them in OpenSearch", + run: runReindexGuests, + }, + "backfill-hotel-departments": { + description: "Seed default departments for hotels that have no departments", + run: runBackfillHotelDepartments, + }, +} + +func main() { + flag.Usage = printUsage + flag.Parse() + + args := flag.Args() + if len(args) == 0 { + printUsage() + os.Exit(1) + } + + name := args[0] + cmd, ok := commands[name] + if !ok { + fmt.Fprintf(os.Stderr, "unknown command: %q\n\n", name) + printUsage() + os.Exit(1) + } + + ctx := context.Background() + var cfg config.Config + if err := envconfig.Process(ctx, &cfg); err != nil { + log.Fatal("failed to process config:", err) + } + + if err := cmd.run(ctx, cfg, args[1:]); err != nil { + log.Fatalf("%s: %v", name, err) + } +} + +func printUsage() { + fmt.Fprintf(os.Stderr, "Usage: cli [args]\n\nAvailable commands:\n") + for name, cmd := range commands { + fmt.Fprintf(os.Stderr, " %-20s %s\n", name, cmd.description) + } + fmt.Fprintln(os.Stderr) +} + +// ============================================================================= +// Command implementations +// ============================================================================= + +func runSyncUsers(ctx context.Context, cfg config.Config, _ []string) error { + repo, err := storage.NewRepository(cfg.DB) + if err != nil { + return fmt.Errorf("failed to connect to db: %w", err) + } + defer repo.Close() + + usersRepo := repository.NewUsersRepository(repo.DB) + + users, err := clerk.FetchUsersFromClerk(cfg.BaseURL+"/users", cfg.SecretKey) + if err != nil { + return err + } + + transformed, err := clerk.ValidateAndReformatUserData(users) + if err != nil { + return err + } + + if err := usersRepo.BulkInsertUsers(ctx, transformed); err != nil { + return fmt.Errorf("failed to insert users: %w", err) + } + + fmt.Println("sync-users completed successfully") + return nil +} diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go index 2abcdf33b..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" validate:"notblank" example:"org_550e8400-e29b-41d4-a716-446655440000"` + 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"` @@ -25,19 +25,12 @@ type CreateUserWebhook struct { ClerkUser `json:"data"` } -type ClerkOrganizationMembership struct { - Organization struct { - ID string `json:"id"` - } `json:"organization"` -} - type ClerkUser struct { - ID string `json:"id"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - ImageUrl *string `json:"image_url"` - HasImage bool `json:"has_image"` - OrganizationMemberships []ClerkOrganizationMembership `json:"organization_memberships"` + ID string `json:"id" example:"user123402"` + FirstName string `json:"first_name" example:"John"` + LastName string `json:"last_name" example:"Doe"` + ImageUrl *string `json:"image_url" example:"https://photo.com/john.jpg"` + HasImage bool `json:"has_image" example:"true"` } type User struct { diff --git a/backend/internal/service/clerk/sync-users.go b/backend/internal/service/clerk/sync-users.go index 9d7c784cc..f7f7f8ee9 100644 --- a/backend/internal/service/clerk/sync-users.go +++ b/backend/internal/service/clerk/sync-users.go @@ -3,11 +3,24 @@ package clerk import ( "encoding/json" "net/http" + + "github.com/generate/selfserve/internal/handler" "github.com/generate/selfserve/internal/models" ) +func ValidateAndReformatUserData(users []models.ClerkUser) ([]*models.CreateUser, error) { + var reformatedUsers []*models.CreateUser + for _, user := range users { + if err := handler.ValidateCreateUserClerk(&user); err != nil { + return nil, err + } + reformatedUsers = append(reformatedUsers, handler.ReformatUserData(&user)) + } + return reformatedUsers, nil +} + func FetchUsersFromClerk(clerkApiUrl string, clerkSecret string) ([]models.ClerkUser, error) { - req, err := http.NewRequest("GET", clerkApiUrl+"?with_organization_memberships=true", nil) + req, err := http.NewRequest("GET", clerkApiUrl, nil) if err != nil { return nil, err }