diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index d768043d..a6deaa39 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -42,7 +42,7 @@ definitions: example: John type: string hotel_id: - example: hotel_123 + example: 550e8400-e29b-41d4-a716-446655440000 type: string id: example: user_123 @@ -299,7 +299,7 @@ definitions: minimum: 1 type: integer id: - example: "550e8400-e29b-41d4-a\t716-446655440000" + example: org_2abc123 type: string name: example: Hotel California @@ -389,6 +389,48 @@ definitions: user_id: type: string type: object + RequestPatchInput: + properties: + completed_at: + type: string + department: + type: string + description: + type: string + estimated_completion_time: + type: integer + guest_id: + type: string + name: + type: string + notes: + type: string + priority: + enum: + - low + - medium + - high + type: string + request_category: + type: string + request_type: + type: string + reservation_id: + type: string + room_id: + type: string + scheduled_time: + type: string + status: + enum: + - pending + - assigned + - in progress + - completed + type: string + user_id: + type: string + type: object RegisterDeviceTokenInput: properties: platform: @@ -559,7 +601,7 @@ definitions: example: John type: string hotel_id: - example: hotel_123 + example: 550e8400-e29b-41d4-a716-446655440000 type: string id: example: user_123 @@ -1093,6 +1135,48 @@ paths: summary: creates a request tags: - requests + /request/{id}: + put: + consumes: + - application/json + description: Partially updates a request — only fields present in the body are + applied; omitted fields keep their current values + parameters: + - description: Request ID (UUID) + in: path + name: id + required: true + type: string + - description: Fields to update + in: body + name: request + required: true + schema: + $ref: '#/definitions/RequestPatchInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Update a request + tags: + - requests /request/cursor: post: consumes: @@ -1286,6 +1370,44 @@ paths: summary: List rooms with filters tags: - rooms + /rooms/{id}: + get: + description: Retrieves a single room by its UUID + parameters: + - description: Hotel ID (UUID) + in: header + name: X-Hotel-ID + required: true + type: string + - description: Room ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/RoomWithOptionalGuestBooking' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Get room by ID + tags: + - rooms /rooms/floors: get: description: Retrieves all distinct floor numbers diff --git a/backend/internal/handler/requests.go b/backend/internal/handler/requests.go index 66b5e57d..80c0e465 100644 --- a/backend/internal/handler/requests.go +++ b/backend/internal/handler/requests.go @@ -74,19 +74,45 @@ func (r *RequestsHandler) CreateRequest(c *fiber.Ctx) error { return c.JSON(res) } +// UpdateRequest godoc +// @Summary Update a request +// @Description Partially updates a request — only fields present in the body are applied; omitted fields keep their current values +// @Tags requests +// @Accept json +// @Produce json +// @Param id path string true "Request ID (UUID)" +// @Param request body models.RequestPatchInput true "Fields to update" +// @Success 200 {object} models.Request +// @Failure 400 {object} errs.HTTPError +// @Failure 404 {object} errs.HTTPError +// @Failure 500 {object} errs.HTTPError +// @Security BearerAuth +// @Router /request/{id} [put] func (r *RequestsHandler) UpdateRequest(c *fiber.Ctx) error { id := c.Params("id") if !validUUID(id) { return errs.BadRequest("request id is not a valid UUID") } - var requestBody models.MakeRequest - if err := httpx.BindAndValidate(c, &requestBody); err != nil { + var patchInput models.RequestPatchInput + if err := httpx.BindAndValidate(c, &patchInput); err != nil { return err } - res, err := r.RequestRepository.InsertRequest(c.Context(), &models.Request{ID: id, MakeRequest: requestBody}) + request, err := r.RequestRepository.FindRequest(c.Context(), id) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("Request", "id", id) + } + slog.Error("failed to get request for update", "err", err, "requestID", id) + return errs.InternalServerError() + } + + request.ApplyPatch(&patchInput) + + res, err := r.RequestRepository.InsertRequest(c.Context(), request) if err != nil { + slog.Error("failed to insert updated request version", "err", err, "requestID", id) return errs.InternalServerError() } diff --git a/backend/internal/handler/requests_test.go b/backend/internal/handler/requests_test.go index bf576239..f400b0c0 100644 --- a/backend/internal/handler/requests_test.go +++ b/backend/internal/handler/requests_test.go @@ -1264,6 +1264,304 @@ func TestRequestHandler_GetRequestsByGuest(t *testing.T) { }) } +func TestRequestHandler_UpdateRequest(t *testing.T) { + t.Parallel() + + const validID = "530e8400-e458-41d4-a716-446655440000" + + t.Run("returns 200 when body is empty object (no fields to patch)", func(t *testing.T) { + t.Parallel() + + var inserted *models.Request + mock := &mockRequestRepository{ + findRequestFunc: func(_ context.Context, id string) (*models.Request, error) { + return &models.Request{ + ID: id, + CreatedAt: time.Now(), + RequestVersion: time.Now(), + MakeRequest: models.MakeRequest{ + HotelID: "521e8400-e458-41d4-a716-446655440000", + Name: "room cleaning", + RequestType: "recurring", + Status: "pending", + Priority: "high", + }, + }, nil + }, + makeRequestFunc: func(_ context.Context, req *models.Request) (*models.Request, error) { + inserted = req + return req, nil + }, + } + + app := fiber.New() + h := NewRequestsHandler(mock, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + require.NotNil(t, inserted) + }) + + t.Run("returns 200 and updated request on success", func(t *testing.T) { + t.Parallel() + + updated := "assigned" + insertedStatus := "" + mock := &mockRequestRepository{ + findRequestFunc: func(_ context.Context, id string) (*models.Request, error) { + return &models.Request{ + ID: id, + CreatedAt: time.Now(), + RequestVersion: time.Now(), + MakeRequest: models.MakeRequest{ + HotelID: "521e8400-e458-41d4-a716-446655440000", + Name: "room cleaning", + RequestType: "recurring", + Status: "pending", + Priority: "high", + }, + }, nil + }, + makeRequestFunc: func(_ context.Context, req *models.Request) (*models.Request, error) { + insertedStatus = req.Status + return req, nil + }, + } + + app := fiber.New() + h := NewRequestsHandler(mock, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + body := `{"status": "` + updated + `"}` + req := httptest.NewRequest("PUT", "/request/"+validID, 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) + + b, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(b), validID) + assert.Equal(t, updated, insertedStatus) + }) + + t.Run("passes only provided fields to patch", func(t *testing.T) { + t.Parallel() + + var inserted *models.Request + + mock := &mockRequestRepository{ + findRequestFunc: func(_ context.Context, id string) (*models.Request, error) { + return &models.Request{ + ID: id, + MakeRequest: models.MakeRequest{ + HotelID: "521e8400-e458-41d4-a716-446655440000", + Name: "old name", + Status: "pending", + Priority: "low", + RequestType: "one-time", + }, + }, nil + }, + makeRequestFunc: func(_ context.Context, req *models.Request) (*models.Request, error) { + inserted = req + return req, nil + }, + } + + app := fiber.New() + h := NewRequestsHandler(mock, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{"name": "new name"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + require.NotNil(t, inserted) + assert.Equal(t, "new name", inserted.Name) + assert.Equal(t, "pending", inserted.Status) + assert.Equal(t, "low", inserted.Priority) + }) + + t.Run("returns 400 when id is not a valid UUID", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(&mockRequestRepository{}, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/not-a-uuid", bytes.NewBufferString(`{"status": "pending"}`)) + 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() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(&mockRequestRepository{}, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, 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 400 on invalid status enum", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(&mockRequestRepository{}, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{"status": "invalid-status"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + b, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(b), "status") + }) + + t.Run("accepts in progress status enum", func(t *testing.T) { + t.Parallel() + + mock := &mockRequestRepository{ + findRequestFunc: func(_ context.Context, id string) (*models.Request, error) { + return &models.Request{ + ID: id, + CreatedAt: time.Now(), + RequestVersion: time.Now(), + MakeRequest: models.MakeRequest{ + HotelID: "521e8400-e458-41d4-a716-446655440000", + Name: "room cleaning", + RequestType: "recurring", + Status: "pending", + Priority: "high", + }, + }, nil + }, + makeRequestFunc: func(_ context.Context, req *models.Request) (*models.Request, error) { + require.Equal(t, "in progress", req.Status) + return req, nil + }, + } + + app := fiber.New() + h := NewRequestsHandler(mock, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{"status":"in progress"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) + + t.Run("returns 400 on invalid priority enum", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(&mockRequestRepository{}, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{"priority": "urgent"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + b, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(b), "priority") + }) + + t.Run("returns 400 when name is blank", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(&mockRequestRepository{}, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{"name": " "}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + b, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(b), "name") + }) + + t.Run("returns 400 when request_type is blank", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(&mockRequestRepository{}, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{"request_type":" "}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + b, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(b), "request_type") + }) + + t.Run("returns 404 when request not found", func(t *testing.T) { + t.Parallel() + + mock := &mockRequestRepository{ + findRequestFunc: func(_ context.Context, _ string) (*models.Request, error) { + return nil, errs.ErrNotFoundInDB + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(mock, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{"status": "pending"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) + }) + + t.Run("returns 500 on db error", func(t *testing.T) { + t.Parallel() + + mock := &mockRequestRepository{ + findRequestFunc: func(_ context.Context, _ string) (*models.Request, error) { + return nil, errors.New("db connection failed") + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(mock, nil, nil) + app.Put("/request/:id", h.UpdateRequest) + + req := httptest.NewRequest("PUT", "/request/"+validID, bytes.NewBufferString(`{"status": "pending"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 500, resp.StatusCode) + }) +} + func TestRequestHandler_GetRequestsByRoomID(t *testing.T) { t.Parallel() diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index 6f411ebc..478c94b1 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -1,6 +1,10 @@ package models -import "time" +import ( + "time" + + "github.com/generate/selfserve/internal/utils" +) type RequestStatus string @@ -57,6 +61,44 @@ type MakeRequest struct { Notes *string `json:"notes" example:"No special requests"` } //@name MakeRequest +// RequestPatchInput is the body for PUT /request/:id — all fields are optional. +// Only non-nil fields are applied; the rest are copied from the current version. +type RequestPatchInput struct { + UserID *string `json:"user_id"` + GuestID *string `json:"guest_id"` + ReservationID *string `json:"reservation_id"` + Name *string `json:"name" validate:"omitempty,notblank"` + Description *string `json:"description"` + RoomID *string `json:"room_id"` + RequestCategory *string `json:"request_category"` + RequestType *string `json:"request_type" validate:"omitempty,notblank"` + Department *string `json:"department"` + Status *string `json:"status" validate:"omitempty,oneof='pending' 'assigned' 'in progress' 'completed'"` + Priority *string `json:"priority" validate:"omitempty,oneof=low medium high"` + EstimatedCompletionTime *int `json:"estimated_completion_time"` + ScheduledTime *time.Time `json:"scheduled_time"` + CompletedAt *time.Time `json:"completed_at"` + Notes *string `json:"notes"` +} //@name RequestPatchInput + +func (r *Request) ApplyPatch(patch *RequestPatchInput) { + utils.ApplyPtr(&r.UserID, patch.UserID) + utils.ApplyPtr(&r.GuestID, patch.GuestID) + utils.ApplyPtr(&r.ReservationID, patch.ReservationID) + utils.Apply(&r.Name, patch.Name) + utils.ApplyPtr(&r.Description, patch.Description) + utils.ApplyPtr(&r.RoomID, patch.RoomID) + utils.ApplyPtr(&r.RequestCategory, patch.RequestCategory) + utils.Apply(&r.RequestType, patch.RequestType) + utils.ApplyPtr(&r.Department, patch.Department) + utils.Apply(&r.Status, patch.Status) + utils.Apply(&r.Priority, patch.Priority) + utils.ApplyPtr(&r.EstimatedCompletionTime, patch.EstimatedCompletionTime) + utils.ApplyPtr(&r.ScheduledTime, patch.ScheduledTime) + utils.ApplyPtr(&r.CompletedAt, patch.CompletedAt) + utils.ApplyPtr(&r.Notes, patch.Notes) +} + type GetRequestsByStatusInput struct { HotelID string `json:"-" label:"X-Hotel-ID" validate:"notblank,uuid"` Status string `json:"status" label:"Status" validate:"oneof='pending' 'assigned' 'in progress' 'completed'"` diff --git a/backend/internal/utils/patch.go b/backend/internal/utils/patch.go new file mode 100644 index 00000000..48d15b1c --- /dev/null +++ b/backend/internal/utils/patch.go @@ -0,0 +1,21 @@ +package utils + +// Apply sets *dst to *src when src is non-nil, leaving dst unchanged otherwise. +// Use this to apply optional patch fields onto an existing value. +// +// utils.Apply(¤t.Status, patch.Status) +func Apply[T any](dst *T, src *T) { + if src != nil { + *dst = *src + } +} + +// ApplyPtr sets dst to src when src is non-nil, leaving dst unchanged otherwise. +// Use this for pointer fields on the target struct. +// +// utils.ApplyPtr(¤t.UserID, patch.UserID) +func ApplyPtr[T any](dst **T, src *T) { + if src != nil { + *dst = src + } +}