diff --git a/backend/.env.sample b/backend/.env.sample index 118acd49..ffa7b700 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -5,6 +5,10 @@ DB_PORT="" DB_NAME="" PORT="8080" APP_LOG_LEVEL="info" +AWS_S3_ACCESS_KEY_ID="" +AWS_S3_SECRET_ACCESS_KEY_ID="" +AWS_S3_REGION="" +AWS_S3_BUCKET_NAME="" DEV_CLERK_SECRET_KEY="secret" DEV_CLERK_WEBHOOK_SIGNATURE="secret" diff --git a/backend/cmd/clerk/sync_test.go b/backend/cmd/clerk/sync_test.go index d978295f..e2a7af93 100644 --- a/backend/cmd/clerk/sync_test.go +++ b/backend/cmd/clerk/sync_test.go @@ -8,6 +8,7 @@ import ( "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" ) @@ -24,6 +25,29 @@ func (m *mockUsersRepositorySync) BulkInsertUsers(ctx context.Context, users []* 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 +} + +// Makes the compiler verify the mock implements the interface +var _ storage.UsersRepository = (*mockUsersRepositorySync)(nil) + func TestSyncUsers(t *testing.T) { t.Parallel() diff --git a/backend/config/config.go b/backend/config/config.go index b1563c5a..e80402fc 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -3,8 +3,8 @@ package config type Config struct { Application `env:",prefix=APP_"` DB `env:",prefix=DB_"` + S3 `env:",prefix=AWS_S3_"` LLM `env:",prefix=LLM_"` Clerk `env:",prefix=CLERK_"` - S3 `env:",prefix=AWS_S3_"` OpenSearch `env:",prefix=OPENSEARCH_"` } diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f02eac0c..182e8c6e 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -595,6 +595,13 @@ definitions: description: nil when no more pages type: string type: object + internal_handler.UpdateProfilePictureRequest: + description: Request body containing the S3 key after uploading + properties: + key: + example: profile-pictures/user123/1706540000.jpg + type: string + type: object host: localhost:8080 info: contact: @@ -1246,6 +1253,114 @@ paths: summary: Get Floors tags: - rooms + /s3/presigned-get-url/{key}: + get: + consumes: + - application/json + description: Generates a presigned URL for a file. The key is the full S3 path + (e.g., profile-pictures/user123/image.jpg) + parameters: + - description: File key (full path after /presigned-url/) + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: Presigned URL + schema: + type: string + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Generate a presigned URL for a file + tags: + - s3 + /s3/presigned-url/{key}: + get: + consumes: + - application/json + description: Generates a presigned URL for a file. The key is the full S3 path + (e.g., profile-pictures/user123/image.jpg) + parameters: + - description: File key (full path after /presigned-url/) + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: Presigned URL + schema: + type: string + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Generate a presigned URL for a file + tags: + - s3 + /s3/upload-url/{userId}: + get: + description: Generates a presigned S3 URL and unique key for uploading a profile + picture. After uploading to S3, use PUT /users/{userId}/profile-picture to + save the key. + parameters: + - description: User ID + in: path + name: userId + required: true + type: string + - default: jpg + description: File extension (jpg, jpeg, png, webp) + in: query + name: ext + type: string + produces: + - application/json + responses: + "200": + description: Returns presigned_url and key + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get presigned URL for profile picture upload + tags: + - s3 /users: post: consumes: @@ -1320,14 +1435,14 @@ paths: put: consumes: - application/json - description: Updates fields on a user + description: Updates allowed fields on a user parameters: - description: User ID in: path name: id required: true type: string - - description: User update data + - description: Fields to update in: body name: request required: true @@ -1360,7 +1475,124 @@ paths: type: object security: - BearerAuth: [] - summary: Updates a user + summary: Update user + tags: + - users + /users/{userId}/profile-picture: + delete: + consumes: + - application/json + description: Deletes the user's profile picture from the database + parameters: + - description: User ID + in: path + name: userId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Delete user's profile picture + tags: + - users + get: + consumes: + - application/json + description: Retrieves the user's profile picture key and returns a presigned + URL for display + parameters: + - description: User ID + in: path + name: userId + required: true + type: string + produces: + - application/json + responses: + "200": + description: Returns key and presigned_url if profile picture exists + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: No profile picture found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get user's profile picture + tags: + - users + put: + consumes: + - application/json + description: Saves the S3 key to the user's profile after the image has been + uploaded to S3 + parameters: + - description: User ID + in: path + name: userId + required: true + type: string + - description: S3 key from upload + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handler.UpdateProfilePictureRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Update user's profile picture tags: - users schemes: diff --git a/backend/internal/handler/s3.go b/backend/internal/handler/s3.go index 7cbea75c..fa13299e 100644 --- a/backend/internal/handler/s3.go +++ b/backend/internal/handler/s3.go @@ -1,14 +1,23 @@ package handler import ( + "fmt" "time" "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/models" s3storage "github.com/generate/selfserve/internal/service/s3" "github.com/gofiber/fiber/v2" ) -const expirationTime = 5 * time.Minute // Moved to a package level constant for reusability +const expirationTime = 5 * time.Minute + +var allowedProfilePictureExts = map[string]struct{}{ + "jpg": {}, + "jpeg": {}, + "png": {}, + "webp": {}, +} type S3Handler struct { S3Storage *s3storage.Storage @@ -18,28 +27,92 @@ func NewS3Handler(s3Storage *s3storage.Storage) *S3Handler { return &S3Handler{S3Storage: s3Storage} } -// GeneratePresignedURL godoc +// GeneratePresignedUploadURL godoc // @Summary Generate a presigned URL for a file -// @Description Generates a presigned URL for a file +// @Description Generates a presigned URL for a file. The key is the full S3 path (e.g., profile-pictures/user123/image.jpg) // @Tags s3 // @Accept json // @Produce json -// @Param key path string true "File key" -// @Success 200 {object} map[string]string "Presigned URL response" +// @Param key path string true "File key (full path after /presigned-url/)" +// @Success 200 {string} string "Presigned URL" // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string -// @Security BearerAuth // @Router /s3/presigned-url/{key} [get] +func (h *S3Handler) GeneratePresignedUploadURL(c *fiber.Ctx) error { + key := c.Params("*") + if key == "" { + return errs.BadRequest("key is required") + } + + presignedURL, err := h.S3Storage.GeneratePresignedUploadURL(c.Context(), models.PresignedURLInput{ + Key: key, + Expiration: expirationTime, + }) + if err != nil { + return err + } + + return c.JSON(fiber.Map{ + "presigned_url": presignedURL, + }) +} + +// GetUploadURL godoc +// @Summary Get presigned URL for profile picture upload +// @Description Generates a presigned S3 URL and unique key for uploading a profile picture. After uploading to S3, use PUT /users/{userId}/profile-picture to save the key. +// @Tags s3 +// @Produce json +// @Param userId path string true "User ID" +// @Param ext query string false "File extension (jpg, jpeg, png, webp)" default(jpg) +// @Success 200 {object} map[string]string "Returns presigned_url and key" +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /s3/upload-url/{userId} [get] +func (h *S3Handler) GetUploadURL(c *fiber.Ctx) error { + userId := c.Params("userId") + ext := c.Query("ext", "jpg") + if _, ok := allowedProfilePictureExts[ext]; !ok { + return errs.BadRequest("invalid extension") + } + + key := fmt.Sprintf("profile-pictures/%s/%d.%s", userId, time.Now().Unix(), ext) + presignedURL, err := h.S3Storage.GeneratePresignedUploadURL(c.Context(), models.PresignedURLInput{ + Key: key, + Expiration: expirationTime, + }) + if err != nil { + return err + } + + return c.JSON(fiber.Map{ + "presigned_url": presignedURL, + "key": key, + }) +} -func (h *S3Handler) GeneratePresignedURL(c *fiber.Ctx) error { - key := c.Params("key") +// GeneratePresignedGetURL godoc +// @Summary Generate a presigned URL for a file +// @Description Generates a presigned URL for a file. The key is the full S3 path (e.g., profile-pictures/user123/image.jpg) +// @Tags s3 +// @Accept json +// @Produce json +// @Param key path string true "File key (full path after /presigned-url/)" +// @Success 200 {string} string "Presigned URL" +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /s3/presigned-get-url/{key} [get] +func (h *S3Handler) GeneratePresignedGetURL(c *fiber.Ctx) error { + key := c.Params("*") if key == "" { return errs.BadRequest("key is required") } - presignedURL, err := h.S3Storage.GeneratePresignedURL(c.Context(), key, expirationTime) + presignedURL, err := h.S3Storage.GeneratePresignedGetURL(c.Context(), models.PresignedURLInput{ + Key: key, + Expiration: expirationTime, + }) if err != nil { - return errs.InternalServerError() + return err } return c.JSON(fiber.Map{ diff --git a/backend/internal/handler/users.go b/backend/internal/handler/users.go index e505dd1b..3d18ab4f 100644 --- a/backend/internal/handler/users.go +++ b/backend/internal/handler/users.go @@ -1,28 +1,30 @@ package handler import ( - "context" "errors" "log/slog" + "time" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/httpx" "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" "github.com/gofiber/fiber/v2" ) -type UsersRepository interface { - FindUser(ctx context.Context, id string) (*models.User, error) - InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) - UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) +// UpdateProfilePictureRequest represents the request body for updating a profile picture +// @Description Request body containing the S3 key after uploading +type UpdateProfilePictureRequest struct { + Key string `json:"key" validate:"notblank" example:"profile-pictures/user123/1706540000.jpg"` } type UsersHandler struct { - repo UsersRepository + UsersRepository storage.UsersRepository + S3Storage storage.S3Storage } -func NewUsersHandler(repo UsersRepository) *UsersHandler { - return &UsersHandler{repo: repo} +func NewUsersHandler(repo storage.UsersRepository, s3 storage.S3Storage) *UsersHandler { + return &UsersHandler{UsersRepository: repo, S3Storage: s3} } // GetUserByID godoc @@ -42,7 +44,7 @@ func (h *UsersHandler) GetUserByID(c *fiber.Ctx) error { if id == "" { return errs.BadRequest("id is required") } - user, err := h.repo.FindUser(c.Context(), id) + user, err := h.UsersRepository.FindUser(c.Context(), id) if err != nil { if errors.Is(err, errs.ErrNotFoundInDB) { return errs.NotFound("user", "id", id) @@ -53,66 +55,171 @@ func (h *UsersHandler) GetUserByID(c *fiber.Ctx) error { return c.JSON(user) } -// UpdateUser godoc -// @Summary Updates a user -// @Description Updates fields on a user +// CreateUser godoc +// @Summary Creates a user +// @Description Creates a user with the given data // @Tags users // @Accept json // @Produce json -// @Param id path string true "User ID" -// @Param request body models.UpdateUser true "User update data" +// @Param request body models.CreateUser true "User data" // @Success 200 {object} models.User // @Failure 400 {object} map[string]string -// @Failure 404 {object} map[string]string // @Failure 500 {object} map[string]string // @Security BearerAuth +// @Router /users [post] +func (h *UsersHandler) CreateUser(c *fiber.Ctx) error { + var CreateUserRequest models.CreateUser + if err := httpx.BindAndValidate(c, &CreateUserRequest); err != nil { + return err + } + + res, err := h.UsersRepository.InsertUser(c.Context(), &CreateUserRequest) + if err != nil { + return errs.InternalServerError() + } + + return c.JSON(res) +} + +// UpdateUser godoc +// @Summary Update user +// @Description Updates allowed fields on a user +// @Tags users +// @Accept json +// @Produce json +// @Param id path string true "User ID" +// @Param request body models.UpdateUser true "Fields to update" +// @Success 200 {object} models.User +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security BearerAuth // @Router /users/{id} [put] func (h *UsersHandler) UpdateUser(c *fiber.Ctx) error { id := c.Params("id") - - var updateUserRequest models.UpdateUser - if err := httpx.BindAndValidate(c, &updateUserRequest); err != nil { + if id == "" { + return errs.BadRequest("id is required") + } + var req models.UpdateUser + if err := httpx.BindAndValidate(c, &req); err != nil { return err } - - user, err := h.repo.UpdateUser(c.Context(), id, &updateUserRequest) + user, err := h.UsersRepository.UpdateUser(c.Context(), id, &req) if err != nil { if errors.Is(err, errs.ErrNotFoundInDB) { return errs.NotFound("user", "id", id) } - slog.Error("failed to update user", "id", id, "err", err.Error()) + slog.Error(err.Error()) return errs.InternalServerError() } - return c.JSON(user) } -// CreateUser godoc -// @Summary Creates a user -// @Description Creates a user with the given data +// GetProfilePicture godoc +// @Summary Get user's profile picture +// @Description Retrieves the user's profile picture key and returns a presigned URL for display // @Tags users // @Accept json // @Produce json -// @Param request body models.CreateUser true "User data" -// @Success 200 {object} models.User -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Security BearerAuth -// @Router /users [post] -func (h *UsersHandler) CreateUser(c *fiber.Ctx) error { - var CreateUserRequest models.CreateUser - if err := c.BodyParser(&CreateUserRequest); err != nil { - return errs.InvalidJSON() +// @Param userId path string true "User ID" +// @Success 200 {object} map[string]string "Returns key and presigned_url if profile picture exists" +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string "No profile picture found" +// @Failure 500 {object} map[string]string +// @Router /users/{userId}/profile-picture [get] +func (h *UsersHandler) GetProfilePicture(c *fiber.Ctx) error { + userId := c.Params("userId") + if userId == "" { + return errs.BadRequest("userId is required") } - if err := httpx.BindAndValidate(c, &CreateUserRequest); err != nil { + key, err := h.UsersRepository.GetKey(c.Context(), userId) + if err != nil { + return errs.InternalServerError() + } + + if key == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "message": "No profile picture found", + }) + } + + // Generate presigned URL for displaying the image + presignedURL, err := h.S3Storage.GeneratePresignedGetURL(c.Context(), models.PresignedURLInput{ + Key: key, + Expiration: 5 * time.Minute, + }) + if err != nil { + return err + } + + return c.JSON(fiber.Map{ + "key": key, + "presigned_url": presignedURL, + }) +} + +// UpdateProfilePicture godoc +// @Summary Update user's profile picture +// @Description Saves the S3 key to the user's profile after the image has been uploaded to S3 +// @Tags users +// @Accept json +// @Produce json +// @Param userId path string true "User ID" +// @Param request body UpdateProfilePictureRequest true "S3 key from upload" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /users/{userId}/profile-picture [put] +func (h *UsersHandler) UpdateProfilePicture(c *fiber.Ctx) error { + userId := c.Params("userId") + if userId == "" { + return errs.BadRequest("userId is required") + } + var req UpdateProfilePictureRequest + if err := httpx.BindAndValidate(c, &req); err != nil { return err } + if err := h.UsersRepository.UpdateProfilePicture(c.Context(), userId, req.Key); err != nil { + return errs.InternalServerError() + } + return c.JSON(fiber.Map{ + "message": "Profile picture updated successfully", + }) +} - res, err := h.repo.InsertUser(c.Context(), &CreateUserRequest) +// DeleteProfilePicture godoc +// @Summary Delete user's profile picture +// @Description Deletes the user's profile picture from the database +// @Tags users +// @Accept json +// @Produce json +// @Param userId path string true "User ID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /users/{userId}/profile-picture [delete] +func (h *UsersHandler) DeleteProfilePicture(c *fiber.Ctx) error { + userId := c.Params("userId") + if userId == "" { + return errs.BadRequest("userId is required") + } + key, err := h.UsersRepository.GetKey(c.Context(), userId) if err != nil { return errs.InternalServerError() } - return c.JSON(res) + if key != "" { + if err := h.S3Storage.DeleteFile(c.Context(), key); err != nil { + return errs.InternalServerError() + } + } + + if err := h.UsersRepository.DeleteProfilePicture(c.Context(), userId); err != nil { + return errs.InternalServerError() + } + + return c.JSON(fiber.Map{ + "message": "Profile picture deleted successfully", + }) } diff --git a/backend/internal/handler/users_test.go b/backend/internal/handler/users_test.go index 9f434139..97fcb25e 100644 --- a/backend/internal/handler/users_test.go +++ b/backend/internal/handler/users_test.go @@ -11,19 +11,21 @@ import ( "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// Mock repository - allows us to control what the "database" returns in tests type mockUsersRepository struct { - findUserByIdFunc func(ctx context.Context, id string) (*models.User, error) - insertUserFunc func(ctx context.Context, user *models.CreateUser) (*models.User, error) - updateUserFunc func(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) + findUserByIdFunc func(ctx context.Context, id string) (*models.User, error) + insertUserFunc func(ctx context.Context, user *models.CreateUser) (*models.User, error) + updateProfilePicFunc func(ctx context.Context, userId string, key string) error + deleteProfilePicFunc func(ctx context.Context, userId string) error + getKeyFunc func(ctx context.Context, userId string) (string, error) + bulkInsertUsersFunc func(ctx context.Context, users []*models.CreateUser) error } -// Implement the interface - calls our controllable function func (m *mockUsersRepository) FindUser(ctx context.Context, id string) (*models.User, error) { if m.findUserByIdFunc != nil { return m.findUserByIdFunc(ctx, id) @@ -31,20 +33,87 @@ func (m *mockUsersRepository) FindUser(ctx context.Context, id string) (*models. return nil, nil } -func (m *mockUsersRepository) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) { +func (m *mockUsersRepository) InsertUser( + ctx context.Context, + user *models.CreateUser, +) (*models.User, error) { if m.insertUserFunc != nil { return m.insertUserFunc(ctx, user) } return nil, nil } -func (m *mockUsersRepository) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { - if m.updateUserFunc != nil { - return m.updateUserFunc(ctx, id, update) +func (m *mockUsersRepository) UpdateProfilePicture( + ctx context.Context, + userId string, + key string, +) error { + if m.updateProfilePicFunc != nil { + return m.updateProfilePicFunc(ctx, userId, key) + } + return nil +} + +func (m *mockUsersRepository) DeleteProfilePicture( + ctx context.Context, + userId string, +) error { + if m.deleteProfilePicFunc != nil { + return m.deleteProfilePicFunc(ctx, userId) + } + return nil +} + +func (m *mockUsersRepository) GetKey( + ctx context.Context, + userId string, +) (string, error) { + if m.getKeyFunc != nil { + return m.getKeyFunc(ctx, userId) + } + return "", nil +} + +func (m *mockUsersRepository) BulkInsertUsers( + ctx context.Context, + users []*models.CreateUser, +) error { + if m.bulkInsertUsersFunc != nil { + return m.bulkInsertUsersFunc(ctx, users) } + return nil +} + +func (m *mockUsersRepository) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { return nil, nil } +// Makes the compiler verify the mock +var _ storage.UsersRepository = (*mockUsersRepository)(nil) + +// Mock S3 Storage for testing +type mockS3Storage struct { + deleteFileFunc func(ctx context.Context, key string) error +} + +func (m *mockS3Storage) GeneratePresignedUploadURL(ctx context.Context, in models.PresignedURLInput) (string, error) { + return "", nil +} + +func (m *mockS3Storage) GeneratePresignedGetURL(ctx context.Context, in models.PresignedURLInput) (string, error) { + return "", nil +} + +func (m *mockS3Storage) DeleteFile(ctx context.Context, key string) error { + if m.deleteFileFunc != nil { + return m.deleteFileFunc(ctx, key) + } + return nil +} + +// Makes the compiler verify the S3 mock implements the interface +var _ storage.S3Storage = (*mockS3Storage)(nil) + func TestUsersHandler_GetUserByID(t *testing.T) { t.Parallel() @@ -66,7 +135,7 @@ func TestUsersHandler_GetUserByID(t *testing.T) { } app := fiber.New() - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Get("/users/:id", h.GetUserByID) req := httptest.NewRequest("GET", "/users/550e8400-e29b-41d4-a716-446655440000", nil) @@ -90,7 +159,7 @@ func TestUsersHandler_GetUserByID(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Get("/users/:id", h.GetUserByID) req := httptest.NewRequest("GET", "/users/nonexistent-id", nil) @@ -110,7 +179,7 @@ func TestUsersHandler_GetUserByID(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Get("/users/:id", h.GetUserByID) req := httptest.NewRequest("GET", "/users/some-id", nil) @@ -136,7 +205,7 @@ func TestUsersHandler_GetUserByID_InvalidMethods(t *testing.T) { } app := fiber.New() - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Get("/users/:id", h.GetUserByID) tests := []struct { @@ -165,10 +234,10 @@ func TestUsersHandler_GetUserByID_InvalidMethods(t *testing.T) { func TestUsersHandler_CreateUser(t *testing.T) { t.Parallel() validBody := `{ + "id": "user_123", "first_name": "John", "last_name": "Doe", - "role": "Receptionist", - "id": "user_123" + "role": "Receptionist" }` t.Run("returns 200 on valid user creation", func(t *testing.T) { @@ -185,7 +254,7 @@ func TestUsersHandler_CreateUser(t *testing.T) { } app := fiber.New() - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Post("/users", h.CreateUser) req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(validBody)) @@ -216,17 +285,17 @@ func TestUsersHandler_CreateUser(t *testing.T) { } app := fiber.New() - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Post("/users", h.CreateUser) bodyWithOptionals := `{ + "id": "user_456", "first_name": "Jane", "last_name": "Dow", "role": "Manager", "employee_id": "EMP-67", "department": "Front Desk", - "timezone": "America/New_York", - "id": "user_123" + "timezone": "America/New_York" }` req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(bodyWithOptionals)) @@ -248,7 +317,7 @@ func TestUsersHandler_CreateUser(t *testing.T) { mock := &mockUsersRepository{} app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Post("/users", h.CreateUser) req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(`{invalid json`)) @@ -266,7 +335,7 @@ func TestUsersHandler_CreateUser(t *testing.T) { mock := &mockUsersRepository{} app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Post("/users", h.CreateUser) req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(`{}`)) @@ -289,14 +358,14 @@ func TestUsersHandler_CreateUser(t *testing.T) { mock := &mockUsersRepository{} app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Post("/users", h.CreateUser) invalidTimezoneBody := `{ + "id": "user_789", "first_name": "John", "last_name": "Doe", "role": "Receptionist", - "id": "user_123", "timezone": "Invalid/Not_A_Timezone" }` @@ -322,7 +391,7 @@ func TestUsersHandler_CreateUser(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewUsersHandler(mock) + h := NewUsersHandler(mock, &mockS3Storage{}) app.Post("/users", h.CreateUser) req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(validBody)) @@ -333,33 +402,253 @@ func TestUsersHandler_CreateUser(t *testing.T) { assert.Equal(t, 500, resp.StatusCode) }) +} - t.Run("returns_400_when_id_is_missing", func(t *testing.T) { - body := `{ - "first_name": "John", - "last_name": "Doe", - "role": "Receptionist" - }` +func TestUsersHandler_UpdateProfilePicture(t *testing.T) { + t.Parallel() + + t.Run("returns 200 on valid update", func(t *testing.T) { + t.Parallel() mock := &mockUsersRepository{ - insertUserFunc: func(ctx context.Context, user *models.CreateUser) (*models.User, error) { - return nil, nil + updateProfilePicFunc: func(ctx context.Context, userId string, key string) error { + return nil }, } + app := fiber.New() + h := NewUsersHandler(mock, &mockS3Storage{}) + app.Put("/users/:userId/profile-picture", h.UpdateProfilePicture) + + body := `{"key": "profile-pictures/user123/1706540000.jpg"}` + req := httptest.NewRequest("PUT", "/users/user123/profile-picture", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + respBody, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(respBody), "successfully") + }) + + t.Run("returns 400 when key is missing", func(t *testing.T) { + t.Parallel() + + mock := &mockUsersRepository{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewUsersHandler(mock) - app.Post("/users", h.CreateUser) + h := NewUsersHandler(mock, &mockS3Storage{}) + app.Put("/users/:userId/profile-picture", h.UpdateProfilePicture) - req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(body)) + req := httptest.NewRequest("PUT", "/users/user123/profile-picture", bytes.NewBufferString(`{}`)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req) require.NoError(t, err) assert.Equal(t, 400, resp.StatusCode) - respBody, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(respBody), "id") + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "key") + }) + + t.Run("returns 400 when key is empty string", func(t *testing.T) { + t.Parallel() + + mock := &mockUsersRepository{} + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewUsersHandler(mock, &mockS3Storage{}) + app.Put("/users/:userId/profile-picture", h.UpdateProfilePicture) + + req := httptest.NewRequest("PUT", "/users/user123/profile-picture", bytes.NewBufferString(`{"key": ""}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 400 on invalid JSON", func(t *testing.T) { + t.Parallel() + + mock := &mockUsersRepository{} + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewUsersHandler(mock, &mockS3Storage{}) + app.Put("/users/:userId/profile-picture", h.UpdateProfilePicture) + + req := httptest.NewRequest("PUT", "/users/user123/profile-picture", bytes.NewBufferString(`{invalid`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 500 when repository fails", func(t *testing.T) { + t.Parallel() + + mock := &mockUsersRepository{ + updateProfilePicFunc: func(ctx context.Context, userId string, key string) error { + return errors.New("db error") + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewUsersHandler(mock, &mockS3Storage{}) + app.Put("/users/:userId/profile-picture", h.UpdateProfilePicture) + + body := `{"key": "profile-pictures/user123/1706540000.jpg"}` + req := httptest.NewRequest("PUT", "/users/user123/profile-picture", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 500, resp.StatusCode) + }) +} + +func TestUsersHandler_DeleteProfilePicture(t *testing.T) { + t.Parallel() + + t.Run("returns 200 on successful delete", func(t *testing.T) { + t.Parallel() + + mockRepo := &mockUsersRepository{ + getKeyFunc: func(ctx context.Context, userId string) (string, error) { + return "profile-pictures/user123/1706540000.jpg", nil + }, + deleteProfilePicFunc: func(ctx context.Context, userId string) error { + return nil + }, + } + + mockS3 := &mockS3Storage{ + deleteFileFunc: func(ctx context.Context, key string) error { + return nil + }, + } + + app := fiber.New() + h := &UsersHandler{UsersRepository: mockRepo, S3Storage: mockS3} + app.Delete("/users/:userId/profile-picture", h.DeleteProfilePicture) + + req := httptest.NewRequest("DELETE", "/users/user123/profile-picture", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "deleted successfully") + }) + + t.Run("returns 200 when user has no profile picture", func(t *testing.T) { + t.Parallel() + + mockRepo := &mockUsersRepository{ + getKeyFunc: func(ctx context.Context, userId string) (string, error) { + return "", nil // No existing profile picture + }, + deleteProfilePicFunc: func(ctx context.Context, userId string) error { + return nil + }, + } + + app := fiber.New() + h := &UsersHandler{UsersRepository: mockRepo, S3Storage: &mockS3Storage{}} + app.Delete("/users/:userId/profile-picture", h.DeleteProfilePicture) + + req := httptest.NewRequest("DELETE", "/users/user123/profile-picture", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + }) + + t.Run("returns 500 when GetKey fails", func(t *testing.T) { + t.Parallel() + + mockRepo := &mockUsersRepository{ + getKeyFunc: func(ctx context.Context, userId string) (string, error) { + return "", errors.New("db error") + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := &UsersHandler{UsersRepository: mockRepo, S3Storage: &mockS3Storage{}} + app.Delete("/users/:userId/profile-picture", h.DeleteProfilePicture) + + req := httptest.NewRequest("DELETE", "/users/user123/profile-picture", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 500, resp.StatusCode) + }) + + t.Run("returns 500 when S3 delete fails", func(t *testing.T) { + t.Parallel() + + mockRepo := &mockUsersRepository{ + getKeyFunc: func(ctx context.Context, userId string) (string, error) { + return "profile-pictures/user123/1706540000.jpg", nil + }, + } + + mockS3 := &mockS3Storage{ + deleteFileFunc: func(ctx context.Context, key string) error { + return errors.New("s3 error") + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := &UsersHandler{UsersRepository: mockRepo, S3Storage: mockS3} + app.Delete("/users/:userId/profile-picture", h.DeleteProfilePicture) + + req := httptest.NewRequest("DELETE", "/users/user123/profile-picture", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 500, resp.StatusCode) + }) + + t.Run("returns 500 when DB delete fails", func(t *testing.T) { + t.Parallel() + + mockRepo := &mockUsersRepository{ + getKeyFunc: func(ctx context.Context, userId string) (string, error) { + return "profile-pictures/user123/1706540000.jpg", nil + }, + deleteProfilePicFunc: func(ctx context.Context, userId string) error { + return errors.New("db error") + }, + } + + mockS3 := &mockS3Storage{ + deleteFileFunc: func(ctx context.Context, key string) error { + return nil + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := &UsersHandler{UsersRepository: mockRepo, S3Storage: mockS3} + app.Delete("/users/:userId/profile-picture", h.DeleteProfilePicture) + + req := httptest.NewRequest("DELETE", "/users/user123/profile-picture", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 500, resp.StatusCode) }) } diff --git a/backend/internal/models/s3.go b/backend/internal/models/s3.go new file mode 100644 index 00000000..7a63240a --- /dev/null +++ b/backend/internal/models/s3.go @@ -0,0 +1,9 @@ +package models + +import "time" + +// PresignedURLInput is the input for generating S3 presigned upload/get URLs. +type PresignedURLInput struct { + Key string `json:"key" validate:"notblank" example:"profile-pictures/user123/1706540000.jpg"` + Expiration time.Duration `json:"expiration" validate:"gt=0" swaggertype:"integer" example:"300000000000"` +} //@name PresignedURLInput diff --git a/backend/internal/models/users.go b/backend/internal/models/users.go index 7d695f0f..7f330813 100644 --- a/backend/internal/models/users.go +++ b/backend/internal/models/users.go @@ -16,8 +16,9 @@ type CreateUser struct { PrimaryEmail *string `json:"primary_email,omitempty" validate:"omitempty,email" example:"john@example.com"` } //@name CreateUser +// UpdateUser is the request body for PATCH/PUT user updates (partial fields). type UpdateUser struct { - PhoneNumber *string `json:"phone_number,omitempty" validate:"omitempty,notblank" example:"+11234567890"` + PhoneNumber *string `json:"phone_number,omitempty" validate:"omitempty" example:"+11234567890"` } //@name UpdateUser type CreateUserWebhook struct { diff --git a/backend/internal/repository/users.go b/backend/internal/repository/users.go index b6b5292b..db95463e 100644 --- a/backend/internal/repository/users.go +++ b/backend/internal/repository/users.go @@ -67,6 +67,27 @@ func (r *UsersRepository) InsertUser(ctx context.Context, user *models.CreateUse return createdUser, nil } +func (r *UsersRepository) UpdateProfilePicture(ctx context.Context, userId string, key string) error { + _, err := r.db.Exec(ctx, ` + UPDATE users SET profile_picture = $1 WHERE id = $2 + `, key, userId) + return err +} + +func (r *UsersRepository) GetKey(ctx context.Context, userId string) (string, error) { + var key string + err := r.db.QueryRow(ctx, `SELECT profile_picture FROM users WHERE id=$1`, userId).Scan(&key) + if err != nil { + return "", err + } + return key, nil +} + +func (r *UsersRepository) DeleteProfilePicture(ctx context.Context, userId string) error { + _, err := r.db.Exec(ctx, `UPDATE users SET profile_picture = NULL WHERE id=$1`, userId) + return err +} + func (r *UsersRepository) UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) { var user models.User diff --git a/backend/internal/service/s3/s3storage.go b/backend/internal/service/s3/s3storage.go index 9f6a4b68..c75fd7d6 100644 --- a/backend/internal/service/s3/s3storage.go +++ b/backend/internal/service/s3/s3storage.go @@ -3,13 +3,14 @@ package s3 import ( "context" "fmt" - "time" "github.com/aws/aws-sdk-go-v2/aws" awsConfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/generate/selfserve/config" + "github.com/generate/selfserve/internal/httpx" + "github.com/generate/selfserve/internal/models" ) type Storage struct { @@ -19,7 +20,6 @@ type Storage struct { } func NewS3Storage(cfg config.S3) (*Storage, error) { - // Create AWS config with your credentials awsCfg, err := awsConfig.LoadDefaultConfig(context.Background(), awsConfig.WithRegion(cfg.Region), awsConfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( @@ -32,9 +32,7 @@ func NewS3Storage(cfg config.S3) (*Storage, error) { return nil, fmt.Errorf("failed to create AWS config: %w", err) } - // Create S3 client client := s3.NewFromConfig(awsCfg) - return &Storage{ Client: client, BucketName: cfg.BucketName, @@ -42,25 +40,37 @@ func NewS3Storage(cfg config.S3) (*Storage, error) { }, nil } -func (s *Storage) GeneratePresignedURL(ctx context.Context, key string, expiration time.Duration) (string, error) { - if key == "" { - return "", fmt.Errorf("key is required") - } - if expiration <= 0 { - return "", fmt.Errorf("expiration must be greater than 0") +func (s *Storage) GeneratePresignedUploadURL(ctx context.Context, in models.PresignedURLInput) (string, error) { + if err := httpx.Validate(&in); err != nil { + return "", err } + presignedURL, err := s.URL.PresignPutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.BucketName), - Key: aws.String(key), + Key: aws.String(in.Key), }, func(opts *s3.PresignOptions) { - opts.Expires = expiration - }, - ) - + opts.Expires = in.Expiration + }) if err != nil { - return "", fmt.Errorf("failed to generate presigned URL with key %s: %w", key, err) + return "", fmt.Errorf("failed to generate presigned URL with key %s: %w", in.Key, err) } + return presignedURL.URL, nil +} +func (s *Storage) GeneratePresignedGetURL(ctx context.Context, in models.PresignedURLInput) (string, error) { + if err := httpx.Validate(&in); err != nil { + return "", err + } + + presignedURL, err := s.URL.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.BucketName), + Key: aws.String(in.Key), + }, func(opts *s3.PresignOptions) { + opts.Expires = in.Expiration + }) + if err != nil { + return "", fmt.Errorf("failed to generate presigned get URL with key %s: %w", in.Key, err) + } return presignedURL.URL, nil } @@ -68,6 +78,7 @@ func (s *Storage) DeleteFile(ctx context.Context, key string) error { if key == "" { return fmt.Errorf("key is required") } + _, err := s.Client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(s.BucketName), Key: aws.String(key), @@ -75,6 +86,5 @@ func (s *Storage) DeleteFile(ctx context.Context, key string) error { if err != nil { return fmt.Errorf("failed to delete file with key %s: %w", key, err) } - return nil } diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 2a877c58..086a6633 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -137,7 +137,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo // initialize handler(s) helloHandler := handler.NewHelloHandler() devsHandler := handler.NewDevsHandler(repository.NewDevsRepository(repo.DB)) - usersHandler := handler.NewUsersHandler(repository.NewUsersRepository(repo.DB)) + usersHandler := handler.NewUsersHandler(repository.NewUsersRepository(repo.DB), s3Store) guestsHandler := handler.NewGuestsHandler(repository.NewGuestsRepository(repo.DB), openSearchRepos.Guests) reqsHandler := handler.NewRequestsHandler(repository.NewRequestsRepo(repo.DB), genkitInstance, notifService) hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB)) @@ -177,6 +177,9 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo api.Route("/users", func(r fiber.Router) { r.Get("/:id", usersHandler.GetUserByID) r.Post("/", usersHandler.CreateUser) + r.Get("/:userId/profile-picture", usersHandler.GetProfilePicture) + r.Put("/:userId/profile-picture", usersHandler.UpdateProfilePicture) + r.Delete("/:userId/profile-picture", usersHandler.DeleteProfilePicture) r.Put("/:id", usersHandler.UpdateUser) }) @@ -205,6 +208,13 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo r.Post("/", hotelsHandler.CreateHotel) }) + // s3 routes + api.Route("/s3", func(r fiber.Router) { + r.Get("/presigned-url/*", s3Handler.GeneratePresignedUploadURL) + r.Get("/upload-url/:userId", s3Handler.GetUploadURL) + r.Get("/presigned-get-url/*", s3Handler.GeneratePresignedGetURL) + }) + // rooms routes api.Route("/rooms", func(r fiber.Router) { r.Post("/", roomsHandler.FilterRooms) @@ -216,11 +226,6 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo r.Get("/group_sizes", guestBookingsHandler.GetGroupSizeOptions) }) - // s3 routes - api.Route("/s3", func(r fiber.Router) { - r.Get("/presigned-url/:key", s3Handler.GeneratePresignedURL) - }) - // notification routes api.Route("/notifications", func(r fiber.Router) { r.Get("/", notifHandler.ListNotifications) diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 10a71426..3f7c84eb 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -17,7 +17,12 @@ type NotificationsRepository interface { } type UsersRepository interface { + FindUser(ctx context.Context, id string) (*models.User, error) InsertUser(ctx context.Context, user *models.CreateUser) (*models.User, error) + UpdateUser(ctx context.Context, id string, update *models.UpdateUser) (*models.User, error) + UpdateProfilePicture(ctx context.Context, userId string, key string) error + DeleteProfilePicture(ctx context.Context, userId string) error + GetKey(ctx context.Context, userId string) (string, error) BulkInsertUsers(ctx context.Context, users []*models.CreateUser) error } @@ -50,6 +55,12 @@ type HotelsRepository interface { InsertHotel(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) } +// S3Storage defines the interface for S3 operations +type S3Storage interface { + GeneratePresignedUploadURL(ctx context.Context, in models.PresignedURLInput) (string, error) + GeneratePresignedGetURL(ctx context.Context, in models.PresignedURLInput) (string, error) + DeleteFile(ctx context.Context, key string) error +} type RoomsRepository interface { FindRoomsWithOptionalGuestBookingsByFloor(ctx context.Context, filter *models.FilterRoomsRequest, hotelID string, cursorRoomNumber int) ([]*models.RoomWithOptionalGuestBooking, error) FindAllFloors(ctx context.Context, hotelID string) ([]int, error) diff --git a/backend/internal/tests/clerk_test.go b/backend/internal/tests/clerk_test.go index 12051095..7a90206a 100644 --- a/backend/internal/tests/clerk_test.go +++ b/backend/internal/tests/clerk_test.go @@ -12,6 +12,7 @@ import ( "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/handler" "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -37,6 +38,29 @@ func (m *mockUsersRepositoryClerk) BulkInsertUsers(ctx context.Context, users [] 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 +} + +// Makes the compiler verify the mock implements the interface +var _ storage.UsersRepository = (*mockUsersRepositoryClerk)(nil) + func TestClerkHandler_CreateUser(t *testing.T) { t.Parallel() diff --git a/clients/mobile/app/(tabs)/guests/index.tsx b/clients/mobile/app/(tabs)/guests/index.tsx index 6839ec13..40795fad 100644 --- a/clients/mobile/app/(tabs)/guests/index.tsx +++ b/clients/mobile/app/(tabs)/guests/index.tsx @@ -5,7 +5,7 @@ import { GuestCard } from "@/components/ui/guest-card"; import { router } from "expo-router"; import { useAPIClient } from "@shared/api/client"; import { useInfiniteQuery, InfiniteData } from "@tanstack/react-query"; -import { GuestPage } from "@shared/api/generated/models/guestPage"; +import type { GuestPage } from "@shared"; import { useGetRoomsFloors, useGetGuestBookingsGroupSizes } from "@shared"; import { GuestListHeader } from "@/components/ui/guest-list-header"; import { getFloorConfig, getGroupSizeConfig } from "@/utils"; @@ -70,14 +70,16 @@ export default function GuestsList() { g.id} + keyExtractor={(g, index) => g.id ?? `guest-${index}`} renderItem={({ item }) => ( router.push(`/guests/${item.id}`)} + firstName={item.first_name ?? ""} + lastName={item.last_name ?? ""} + floor={item.floor ?? 0} + room={item.room_number ?? 0} + onPress={() => { + if (item.id) router.push(`/guests/${item.id}`); + }} /> )} onEndReached={() => { diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index 4154f67f..c1902971 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -17,6 +17,7 @@ export type { GenerateRequestWarning, Hotel, Guest, + GuestPage, Dev, } from "./api/generated/models"; diff --git a/clients/web/tsconfig.json b/clients/web/tsconfig.json index 2022b26a..5c468769 100644 --- a/clients/web/tsconfig.json +++ b/clients/web/tsconfig.json @@ -27,11 +27,11 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@shared": ["../shared/src"], - "@shared/*": ["../shared/src/*"] + "@shared/*": ["../shared/src/*"], + "*": ["./*"] } } }