Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,15 @@ definitions:
user_id:
type: string
type: object
RequestsOverview:
properties:
pending:
type: integer
unassigned:
type: integer
urgent:
type: integer
type: object
RoomRequestsResponse:
properties:
assigned:
Expand Down Expand Up @@ -1938,6 +1947,44 @@ paths:
summary: Get requests feed
tags:
- requests
/requests/overview:
post:
consumes:
- application/json
description: Returns counts of urgent (high priority), unassigned, and pending
tasks scoped to the rooms matching the given filters. Accepts the same filter
body as POST /rooms. Does not mutate any data.
parameters:
- description: Hotel ID
in: header
name: X-Hotel-ID
required: true
type: string
- description: Room filters
in: body
name: body
schema:
$ref: '#/definitions/FilterRoomsRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/RequestsOverview'
"400":
description: Bad Request
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 requests overview counts
tags:
- requests
/rooms:
post:
consumes:
Expand Down
33 changes: 33 additions & 0 deletions backend/internal/handler/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,39 @@ func (r *RequestsHandler) GetRequestsFeed(c *fiber.Ctx) error {
return c.JSON(page)
}

// GetRequestsOverview godoc
// @Summary Get requests overview counts
// @Description Returns counts of urgent (high priority), unassigned, and pending tasks scoped to the rooms matching the given filters. Accepts the same filter body as POST /rooms. Does not mutate any data.
// @Tags requests
// @Accept json
// @Produce json
// @Param X-Hotel-ID header string true "Hotel ID"
// @Param body body models.FilterRoomsRequest false "Room filters"
// @Success 200 {object} models.RequestsOverview
// @Failure 400 {object} errs.HTTPError
// @Failure 500 {object} errs.HTTPError
// @Security BearerAuth
// @Router /requests/overview [post]
func (r *RequestsHandler) GetRequestsOverview(c *fiber.Ctx) error {
hotelID, err := hotelIDFromHeader(c)
if err != nil {
return err
}

var body models.FilterRoomsRequest
if err := httpx.BindAndValidate(c, &body); err != nil {
return err
}

overview, err := r.RequestRepository.GetRequestsOverview(c.Context(), hotelID, &body)
if err != nil {
slog.Error("failed to get requests overview", "err", err, "hotelID", hotelID)
return errs.InternalServerError()
}

return c.JSON(overview)
}

// parseFeedCursor decodes a universal cursor: "priority_rank|created_at_nano|id".
// Returns zero values and nil error for an empty cursor (first page).
func parseFeedCursor(cursor string) (id string, createdAt time.Time, priorityRank int, err error) {
Expand Down
178 changes: 178 additions & 0 deletions backend/internal/handler/requests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type mockRequestRepository struct {
findRequestsByRoomIDAndUserIDFunc func(ctx context.Context, roomID, hotelID, userID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error)
findUnassignedRequestsByRoomIDFunc func(ctx context.Context, roomID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error)
findRequestsPaginatedFunc func(ctx context.Context, input *models.RequestsFeedInput, cursorID string, cursorCreatedAt time.Time, cursorPriorityRank int, limit int) ([]*models.GuestRequest, error)
getRequestsOverviewFunc func(ctx context.Context, hotelID string, filters *models.FilterRoomsRequest) (*models.RequestsOverview, error)
}

func (m *mockRequestRepository) InsertRequest(ctx context.Context, req *models.Request) (*models.Request, error) {
Expand Down Expand Up @@ -61,6 +62,10 @@ func (m *mockRequestRepository) FindRequestsPaginated(ctx context.Context, input
return m.findRequestsPaginatedFunc(ctx, input, cursorID, cursorCreatedAt, cursorPriorityRank, limit)
}

func (m *mockRequestRepository) GetRequestsOverview(ctx context.Context, hotelID string, filters *models.FilterRoomsRequest) (*models.RequestsOverview, error) {
return m.getRequestsOverviewFunc(ctx, hotelID, filters)
}

type mockLLMService struct {
runGenerateRequestFunc func(ctx context.Context, input aiflows.GenerateRequestInput) (aiflows.EnrichedGenerateRequestOutput, error)
}
Expand Down Expand Up @@ -1967,3 +1972,176 @@ func TestRequestHandler_GetGenerateRequestStatus(t *testing.T) {
assert.Contains(t, string(body), "Extra Towels Request")
})
}

func TestRequestHandler_GetRequestsOverview(t *testing.T) {
t.Parallel()

const validHotelID = "org_521e8400-e458-41d4-a716-446655440000"

t.Run("returns 200 with counts on success", func(t *testing.T) {
t.Parallel()

mock := &mockRequestRepository{
getRequestsOverviewFunc: func(_ context.Context, _ string, _ *models.FilterRoomsRequest) (*models.RequestsOverview, error) {
return &models.RequestsOverview{Urgent: 3, Unassigned: 5, Pending: 7}, nil
},
}

app := fiber.New()
h := NewRequestsHandler(mock, nil, nil)
app.Post("/requests/overview", h.GetRequestsOverview)

req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

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), `"urgent":3`)
assert.Contains(t, string(body), `"unassigned":5`)
assert.Contains(t, string(body), `"pending":7`)
})

t.Run("returns zeros when no active tasks exist", func(t *testing.T) {
t.Parallel()

mock := &mockRequestRepository{
getRequestsOverviewFunc: func(_ context.Context, _ string, _ *models.FilterRoomsRequest) (*models.RequestsOverview, error) {
return &models.RequestsOverview{}, nil
},
}

app := fiber.New()
h := NewRequestsHandler(mock, nil, nil)
app.Post("/requests/overview", h.GetRequestsOverview)

req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

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), `"urgent":0`)
assert.Contains(t, string(body), `"unassigned":0`)
assert.Contains(t, string(body), `"pending":0`)
})

t.Run("passes hotel_id from header to repository", func(t *testing.T) {
t.Parallel()

var capturedHotelID string
mock := &mockRequestRepository{
getRequestsOverviewFunc: func(_ context.Context, hotelID string, _ *models.FilterRoomsRequest) (*models.RequestsOverview, error) {
capturedHotelID = hotelID
return &models.RequestsOverview{}, nil
},
}

app := fiber.New()
h := NewRequestsHandler(mock, nil, nil)
app.Post("/requests/overview", h.GetRequestsOverview)

req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
assert.Equal(t, validHotelID, capturedHotelID)
})

t.Run("passes filters to repository", func(t *testing.T) {
t.Parallel()

var capturedFilters *models.FilterRoomsRequest
mock := &mockRequestRepository{
getRequestsOverviewFunc: func(_ context.Context, _ string, filters *models.FilterRoomsRequest) (*models.RequestsOverview, error) {
capturedFilters = filters
return &models.RequestsOverview{}, nil
},
}

app := fiber.New()
h := NewRequestsHandler(mock, nil, nil)
app.Post("/requests/overview", h.GetRequestsOverview)

body := `{"floors":[1,2],"status":["occupied"],"attributes":["deluxe"],"advanced":["arrivals-today"]}`
req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)

require.NotNil(t, capturedFilters)
require.NotNil(t, capturedFilters.Floors)
assert.Equal(t, []int{1, 2}, *capturedFilters.Floors)
assert.Equal(t, []string{"occupied"}, capturedFilters.Status)
assert.Equal(t, []string{"deluxe"}, capturedFilters.Attributes)
assert.Equal(t, []string{"arrivals-today"}, capturedFilters.Advanced)
})

t.Run("returns 400 when X-Hotel-ID header is missing", func(t *testing.T) {
t.Parallel()

app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewRequestsHandler(&mockRequestRepository{}, nil, nil)
app.Post("/requests/overview", h.GetRequestsOverview)

req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)

body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "hotel_id")
})

t.Run("returns 400 on invalid JSON body", func(t *testing.T) {
t.Parallel()

app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewRequestsHandler(&mockRequestRepository{}, nil, nil)
app.Post("/requests/overview", h.GetRequestsOverview)

req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{invalid`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})

t.Run("returns 500 on repository error", func(t *testing.T) {
t.Parallel()

mock := &mockRequestRepository{
getRequestsOverviewFunc: func(_ context.Context, _ string, _ *models.FilterRoomsRequest) (*models.RequestsOverview, error) {
return nil, errors.New("db connection failed")
},
}

app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewRequestsHandler(mock, nil, nil)
app.Post("/requests/overview", h.GetRequestsOverview)

req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 500, resp.StatusCode)
})
}
6 changes: 6 additions & 0 deletions backend/internal/models/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ type RoomRequestsResponse struct {
Unassigned []*GuestRequest `json:"unassigned"`
} //@name RoomRequestsResponse

type RequestsOverview struct {
Urgent int `json:"urgent"`
Unassigned int `json:"unassigned"`
Pending int `json:"pending"`
} //@name RequestsOverview

type GuestRequest struct {
ID string `json:"id"`
Name string `json:"name"`
Expand Down
Loading